diff --git a/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml new file mode 100644 index 0000000000..cce84b1ef3 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +2026-07-19-gui-layering-and-rpc-protocol.md: b27d8d024612d890819bfca9b43c0c81464dfdd3 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 3cf4ba6421c7332c1f8cebb61656a1546f3ad45f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md rename to .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 372bf49260..b27d8d0246 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -1,6 +1,7 @@ # Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier Status: implemented +Archived: 2026-08-27 English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md rename to .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index ecce57c01c..3cf4ba6421 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -1,6 +1,7 @@ # Agent Note: GUI 分层与 RPC 协议——host/client 按能力提供方分层、四象限消息模型与 fetch 载体 Status: implemented +Archived: 2026-08-27 [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 diff --git a/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml new file mode 100644 index 0000000000..aad9bee1cc --- /dev/null +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.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/archived/architecture/2026-08-04-websocket-downlink-carrier.md +2026-08-04-websocket-downlink-carrier.md: 5edcdd95cf2845d455a61930a9fc00e7e57e72eb +2026-08-04-websocket-downlink-carrier.zh.md: 213697effb8655583e7c420e58acfd171261a8d8 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md rename to .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md index 420a0d30f3..5edcdd95cf 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md @@ -1,6 +1,7 @@ # Agent Note: WebSocket carrier for browser downlinks Status: implemented +Archived: 2026-08-27 English | [中文](2026-08-04-websocket-downlink-carrier.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md rename to .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md index 5f277a4ed3..213697effb 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -1,6 +1,7 @@ # Agent Note: 浏览器下行 WebSocket 载体 Status: implemented +Archived: 2026-08-27 [English](2026-08-04-websocket-downlink-carrier.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 479a150d56..a770e0ac92 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -25,6 +25,9 @@ "architecture/2026-07-05-windows-fs-permissions.i18n.yaml": "sha256:7e61ee9bbd9de4bf3285a6f250d9625bd062e5fb90279dbffd64c820f1f7fe6b", "architecture/2026-07-05-windows-fs-permissions.md": "sha256:03734da511eae3b0736f7cad73d9da76ae2f69f9d5ed09089b0121ccb135a861", "architecture/2026-07-05-windows-fs-permissions.zh.md": "sha256:454848057ea905fe76c88d17264e71e71fb685f08f82088de6976878372865c3", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml": "sha256:855477999c84236430dc9308e16797eb21658a67a72b8537315c6964ff0c0c0a", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.md": "sha256:3517f37e98e74865dced37d5e1559d443e8fa827031c8335e99a1e910586e9ac", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md": "sha256:8181386d957fa6d6b3eb9b05d29adb10804b5a926853425415d368cc7fceaefa", "architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml": "sha256:1b4822af5c8d642b73e3a0b04fb0a1dea9f50d0147046fbef53f5e49c030fb91", "architecture/2026-07-22-tui-interactive-extension-service.md": "sha256:ca6b2774f4821e66f7c8397f20fcd34926728ded853fa48cbe451db7a8d2f883", "architecture/2026-07-22-tui-interactive-extension-service.zh.md": "sha256:5b060c7626ee796c27108be7467a5e4be0677d7525d383336e7ec31ddce5c303", @@ -43,6 +46,9 @@ "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", + "architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml": "sha256:b9d742d068a0e36df2f3030f6a04a3638f3461f7e10b50ed0cd6bd5e85de5019", + "architecture/2026-08-04-websocket-downlink-carrier.md": "sha256:b9be27a4cda8abd410c6e8b728c571f96b4891eb003c5200e9a0ffbbc9145b42", + "architecture/2026-08-04-websocket-downlink-carrier.zh.md": "sha256:118b71b33710a7a3d28375c48b42c1ec19993ca7e13f286dd6ea64f934456f46", "architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc", "architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf", "architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa", diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 7d8d2db1cf..241dcf71db 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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 .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: 9d70b8048b1d2bb50290d34158d9deb329d5e15e -2026-07-12-agent-scope-runtime-design.zh.md: 278bcede47fee9f67d3d2d2d7135e5357c120161 +2026-07-12-agent-scope-runtime-design.md: 7aebac35d1a5477f0b1d3857e983680ea38bd9f5 +2026-07-12-agent-scope-runtime-design.zh.md: c636bf6f51c58aa5d9f1e3b6f9988b4f4ab5b86c diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 9d70b8048b..7aebac35d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -74,7 +74,7 @@ The receiver is a small carrier rather than a transparent proxy for the domain o Scope-aware registries use `ScopedLayers` to own one eager global aggregate and lazily created identity-keyed aggregates. A read resolves the global layer and at most one exact local layer; it never creates state or traverses parentage. Registration visibility and Cordis effect ownership derive from the same context, and reclamation waits until the concrete layer's complete aggregate is empty ([decision](2026-07-12-scoped-layers-store.md)). -Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved Code Mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view. +Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved PTC mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view. ### Fused dispatch helpers prevent subject drift @@ -206,7 +206,7 @@ Tool presentation and execution share one private resolver. Prompt assembly rema ### One resolver defines the tool view -The private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, Code Mode SDK generation, and restriction validation all use that resolver or its pre-restriction global-name view. +The private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, PTC mode SDK generation, and restriction validation all use that resolver or its pre-restriction global-name view. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) owns the user-visible allow/deny semantics. The implementation requirement is agreement: a filtered-away global cannot remain executable through a different lookup path, and a locally shadowed definition is the same definition presented and executed. @@ -214,11 +214,11 @@ The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-pe ### Tool execution owns identity and boundary materialization -The registry assigns every execution a fresh branded `Symbol` token. Nested Code Mode calls carry the outer token as `parent`, so structured output can correlate an inner capture with its enclosing `run_code` result by identity. +The registry assigns every execution a fresh branded `Symbol` token. Nested PTC mode calls carry the outer token as `parent`, so structured output can correlate an inner capture with its enclosing `run_code` result by identity. A fresh registry-assigned Symbol provides collision-free execution identity without a WeakSet membership registry. Callers cannot supply the execution's own token through `ToolExecutionInput`; they only receive the pipeline-owned `ToolExecution` after the registry creates it. This is a trusted typed contract, not a runtime defense against arbitrary casts or JavaScript callers. -Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks. +Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and PTC mode nesting remain explicit relational checks. After post-execute or outer pipeline normalization, the registry losslessly snapshots the candidate result, converting a snapshot failure into an ordinary error, invokes the call's snapshotted optional `ToolDefinition.finalizeContent` callback, then materializes and freezes the accepted final result once. The callback may replace only content, so structured error identity, contexts, and metadata remain registry-owned even when a tool enforces a last-mile result bound. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are contained individually. An outer pipeline or candidate-snapshot failure is normalized before final content, so observers can discard staged work against the same authoritative boundary. @@ -226,9 +226,9 @@ After post-execute or outer pipeline normalization, the registry losslessly snap SystemPrompt first resolves the global-plus-agent sections, variables, and tool providers into a deterministic registry contribution. The scope-filtered `system-prompt/assemble` waterfall may then reorder, replace, add, or remove any section, variable, or schema. Its returned assembly is authoritative; there is no later restoration pass and no finality metadata on ordinary prompt sections, tool definitions, or provider results. -This is a trusted same-process extension point, not an authority boundary. A listener that changes Code Mode's `run_code` schema or `tools:sdk` instructions, or a structured child's capture schema or instruction, owns preserving a coherent protocol in the assembly it returns. ToolRuntime still reserves `run_code` against ordinary tool registration and restriction because those are registry invariants, but assembly middleware remains free to transform the final model-visible surface. +This is a trusted same-process extension point, not an authority boundary. A listener that changes PTC mode's `run_code` schema or `tools:sdk` instructions, or a structured child's capture schema or instruction, owns preserving a coherent protocol in the assembly it returns. ToolRuntime still reserves `run_code` against ordinary tool registration and restriction because those are registry invariants, but assembly middleware remains free to transform the final model-visible surface. -Scope solves the real isolation problem directly. Structured-output contributions register in the child's exact scope, while Code Mode derives its transport and SDK from the same resolved tool view. A second named-protection system would need another ownership and collision rule across arbitrary schema providers—including providers that intentionally contribute duplicate names—without creating a new trust boundary. +Scope solves the real isolation problem directly. Structured-output contributions register in the child's exact scope, while PTC mode derives its transport and SDK from the same resolved tool view. A second named-protection system would need another ownership and collision rule across arbitrary schema providers—including providers that intentionally contribute duplicate names—without creating a new trust boundary. ### Structured output commits only authoritative outcomes @@ -236,11 +236,11 @@ Structured output combines child-scoped composition with a two-phase execution c For a native call, the observer deletes the stage and commits its value only when that exact execution's final result succeeds. A post-execute block or outer pipeline failure therefore cannot leave a captured value behind. -For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. +For a PTC mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. Once a value is pending or committed, a scoped monotonic guard denies later tool calls. The successful structured-output execution calls `exec.concludeTurn()`, so its own immutable result carries `concludesTurn: true` and the loop ends the tool loop at that step. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. -Pure Code Mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. +Pure PTC mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. ### Three execution boundaries are deliberately one-way @@ -332,7 +332,7 @@ The plugin does not police trusted setup by scanning registries or reject prompt The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, type-equivalence blocks, and scoped-event resolver map are generated or freshness-gated from source. The [TypeScript semantic-gates Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md) owns Program construction, semantic event discovery, and resolver-generation rules. -Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and Code Mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. +Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and PTC mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. ## Alternatives considered @@ -385,7 +385,7 @@ The implementation is smaller and its proof follows the same shape as its owners Scope-aware services still maintain global and identity-keyed maps, and operations must carry their real agent explicitly. Async create/resume and subagent start require callers to await ownership transfer and dispose returned handles. -A trusted `system-prompt/assemble` listener can remove or replace Code Mode and structured-output protocol pieces. This is deliberate: the listener owns final composition and must preserve any protocol the deployment expects to remain usable. +A trusted `system-prompt/assemble` listener can remove or replace PTC mode and structured-output protocol pieces. This is deliberate: the listener owns final composition and must preserve any protocol the deployment expects to remain usable. The design trusts typed plugins in the same process. It does not defend against arbitrary casts, stateful getters, mutation that violates readonly contracts, or a plugin deliberately using ambient service access outside the supported composition API. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 278bcede47..c636bf6f51 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -76,7 +76,7 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.zh.md))。 -每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 +每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 PTC mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 ### 融合 dispatch 辅助函数防止主体漂移 @@ -210,7 +210,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 ### 一个解析器定义工具视图 -私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 +私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、PTC mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 @@ -218,11 +218,11 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 ### 工具执行拥有标识和边界物化 -注册表为每次执行分配一个新的带品牌的 `Symbol` token。嵌套的 Code Mode 调用将外层 token 作为 `parent` 携带,因此结构化输出可以通过标识将内层捕获与其外层 `run_code` 结果关联。 +注册表为每次执行分配一个新的带品牌的 `Symbol` token。嵌套的 PTC mode 调用将外层 token 作为 `parent` 携带,因此结构化输出可以通过标识将内层捕获与其外层 `run_code` 结果关联。 注册表分配的新 Symbol 提供无碰撞的执行标识,无需 WeakSet 成员注册表。调用方无法通过 `ToolExecutionInput` 提供执行自身的 token;它们仅在注册表创建后接收流水线拥有的 `ToolExecution`。这是一个可信的类型化约定,而非针对任意强制转换或 JavaScript 调用方的运行时防御。 -参数在模型/工具 JSON 进入流水线时一次性物化。Pre-、around- 和 post-execute 监听器操作类型化的 execution 和决策。Call ID 关联、审批、单调守卫和 Code Mode 嵌套仍然是显式的关系检查。 +参数在模型/工具 JSON 进入流水线时一次性物化。Pre-、around- 和 post-execute 监听器操作类型化的 execution 和决策。Call ID 关联、审批、单调守卫和 PTC mode 嵌套仍然是显式的关系检查。 在 post-execute 或外层流水线完成规范化后,注册表先为候选结果创建无损快照,并将快照失败转为普通错误;随后调用在本次调用创建时已快照的可选 `ToolDefinition.finalizeContent` 回调,最后一次性物化并冻结被接受的最终结果。该回调只能替换内容,因此即使工具强制最后一道结果上限,结构化错误标识、上下文与元数据仍由注册表拥有。每个同步的 `tools/result` 观察者接收该确切的已提交对象,观察者失败被逐个隔离。外层流水线失败或候选快照失败会在最终内容处理之前被规范化,因此观察者可以丢弃针对同一权威边界的暂存工作。 @@ -230,9 +230,9 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为确定性的注册表贡献。作用域过滤的 `system-prompt/assemble` waterfall 随后可以重排、替换、添加或移除任何段、变量或 schema。其返回的组装结果即为权威;没有后续的恢复步骤,普通提示词段、工具定义或提供方结果上也没有终态元数据。 -这是一个可信的同进程扩展点,而非权限边界。修改 Code Mode 的 `run_code` schema 或 `tools:sdk` 指令,或结构化子级的捕获 schema 或指令的监听器,有责任在其返回的组装中保持协议的一致性。ToolRuntime 仍然保留 `run_code` 不受普通工具注册和限制影响,因为那些是注册表不变式,但 assembly 中间件仍然可以自由变换最终的模型可见表面。 +这是一个可信的同进程扩展点,而非权限边界。修改 PTC mode 的 `run_code` schema 或 `tools:sdk` 指令,或结构化子级的捕获 schema 或指令的监听器,有责任在其返回的组装中保持协议的一致性。ToolRuntime 仍然保留 `run_code` 不受普通工具注册和限制影响,因为那些是注册表不变式,但 assembly 中间件仍然可以自由变换最终的模型可见表面。 -Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 +Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 PTC mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 @@ -242,11 +242,11 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 对于原生调用,观察者仅在该确切执行的最终结果成功时才删除暂存并提交其值。因此 post-execute 阻止或外层流水线失败不会留下已捕获的值。 -对于 Code Mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 +对于 PTC mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。成功的结构化输出执行会调用 `exec.concludeTurn()`,因此其自身不可变结果携带 `concludesTurn: true`,循环在该步骤结束工具循环。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 -纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 +纯 PTC mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 @@ -342,7 +342,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.zh.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 -行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 +行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 PTC mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 @@ -395,7 +395,7 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化 作用域感知服务仍然维护全局和按标识键索引的映射,操作必须显式携带其真实 agent。异步创建/恢复和 subagent start 要求调用方等待所有权转移并 dispose 返回的句柄。 -可信的 `system-prompt/assemble` 监听器可以移除或替换 Code Mode 和结构化输出协议片段。这是有意为之:监听器拥有最终组合,必须保持部署期望仍可用的任何协议。 +可信的 `system-prompt/assemble` 监听器可以移除或替换 PTC mode 和结构化输出协议片段。这是有意为之:监听器拥有最终组合,必须保持部署期望仍可用的任何协议。 该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 约定的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 29807cfb7c..41d4664d7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.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 .agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md -2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8 -2026-07-19-cooperative-tool-cancellation.zh.md: ec35734eef91c5c774d1be814b221e9fdb8f65fa +2026-07-19-cooperative-tool-cancellation.md: 5ca2b44b24a4af189df27c6f724021a7bf290e2b +2026-07-19-cooperative-tool-cancellation.zh.md: d6d49fb44c5d5629729a8b5b4bad07ea9b2f7ee9 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 781202688a..5ca2b44b24 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -16,7 +16,7 @@ Cancellation can arrive before policy, during approval, inside an around-dispatc `ToolExecutionInput.signal` is a required readonly `AbortSignal`. `ToolExecution.signal` and `ToolRunContext.signal` are therefore required and readonly as well. Every typed caller supplies the signal it owns; the registry provides no overload, default controller, never-abort sentinel, or convenience execution path. -`ToolDefinition.execute(args, exec)` keeps its existing signature. `defineTool()` contextually types `exec.signal` as a required `AbortSignal`, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested Code Mode dispatches pass their current operation signal explicitly. +`ToolDefinition.execute(args, exec)` keeps its existing signature. `defineTool()` contextually types `exec.signal` as a required `AbortSignal`, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested PTC mode dispatches pass their current operation signal explicitly. The registry trusts this typed same-process contract. It does not perform runtime `AbortSignal` validation or add hostile-input tests for an omitted or malformed signal. Validation remains at parser/config, model/tool JSON, durable/file, worker, process, and wire boundaries; untyped JavaScript that violates the TypeScript interface has no compatibility contract. @@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making ## Verification -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index ec35734eef..d6d49fb44c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -16,7 +16,7 @@ Status: implemented `ToolExecutionInput.signal` 是必填且只读的 `AbortSignal`,因此 `ToolExecution.signal` 和 `ToolRunContext.signal` 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径。 -`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。 +`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 PTC mode 嵌套调度都会显式传入当前操作的信号。 注册表信任这份类型化同进程约定。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、模型与工具 JSON、持久化与文件、worker、进程和协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性约定。 @@ -46,7 +46,7 @@ Status: implemented ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b80381f031..e48351a2ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 -2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 +2026-07-19-gui-web-client-architecture.md: 703bf2b873eee8afc7e13f89acba99b06fc98745 +2026-07-19-gui-web-client-architecture.zh.md: d61cc8119929b3efc912221df3343918e4851308 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 4448fd5c68..703bf2b873 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-web-client-architecture.zh.md) -> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol note](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. +> Division of labor: the historical channel-independent layering model and RPC protocol are recorded in the [archived layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. ## Problem @@ -17,7 +17,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ``` ┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ │ sessions/agents/SessionLog │ │ client cordis root ctx │ -│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ +│ Connection + Gateway: RPC/events│◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ │ webserver: │ │ ├ immediately entries: connection/runtime/ │ │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ @@ -42,7 +42,7 @@ Implementation homes: registry core and the props-share types live in `packages/ ## Services and scope addressing -A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). +A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (RPC transport + generation state), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). @@ -53,7 +53,7 @@ There is no component registration model besides slots — the former view and t Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): ``` -mux/host frames (ConnectionController pump, injected sinks) +$events frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. -- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering note's territory. +- **ConnectionController** (in `packages/client/connection`): opens the `$events` Remote stream, pumps with for-await, and reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer calls generated namespaces through `ctx.remote`; Web carriage uses HTTP POST for unary Remote calls and API Gateway's WebSocket mux for logical streams, while Connection owns request transport and generations. ## The React face (`packages/client/ui-renderer`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index e8a8121a1a..d61cc81199 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-web-client-architecture.md) | 中文 -> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 +> 分工线:历史上的通道无关分层模型与 RPC 协议见[已归档的分层与 RPC 协议笔记](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 ## Problem @@ -17,7 +17,7 @@ Status: implemented ``` ┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ │ sessions/agents/SessionLog │ │ client cordis root ctx │ -│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ +│ Connection + Gateway: RPC/events│◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ │ webserver: │ │ ├ immediately entries: connection/runtime/ │ │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ @@ -42,7 +42,7 @@ slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chai ## 服务与 scope 寻址 -服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 +服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(RPC 传输 + generation 状态)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.zh.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。 @@ -53,7 +53,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都 帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): ``` -mux/host frames (ConnectionController pump, injected sinks) +$events frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md)拥有 Tool 递归渲染。 -- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.zh.md)载两个 server→client 象限,客户端类族归分层笔记属地。 +- **ConnectionController**(位于 `packages/client/connection`):打开 `$events` Remote 流、通过 for-await 泵入,并在 generation 围栏内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sink 单向注入,Controller 不认识 Session。重连即重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层通过 `ctx.remote` 调用生成的命名空间;Web 载体以 HTTP POST 承载 Remote 一元调用,以 API Gateway 的 WebSocket mux 承载逻辑流,Connection 则拥有请求传输与 generation。 ## React 面(`packages/client/ui-renderer`) diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 84a51ba792..f509835eb1 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.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 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md -2026-07-20-canonical-tool-output-contract.md: 0b3bd788fd1ab63aa6929827ef82e5ba732e5324 -2026-07-20-canonical-tool-output-contract.zh.md: bbcd519de8d02df14be931b44f8dc44fc06b8f6a +2026-07-20-canonical-tool-output-contract.md: 52b56a44a747a07e13e05511d6d40a85fcd9696d +2026-07-20-canonical-tool-output-contract.zh.md: a78defb2b45143d46fed2bedf760df12744621a5 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index 0b3bd788fd..52b56a44a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-canonical-tool-output-contract.zh.md) ## Problem -Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: Code Mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary. +Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: PTC mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary. The durable session contract made that presentation authoritative for replay, but persisting every rich intermediate value would enlarge logs, expose implementation data to compaction and migration, and incorrectly turn an execution-local API into session format. The foundation instead needs one typed value during execution and an explicit projection into the existing durable/model-facing content. @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value. -Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; Code Mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. +Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. The first-party tools preserve their existing Native text while returning domain DTOs: @@ -66,7 +66,7 @@ MCP bridges preserve protocol blocks through `McpResult<{...}> = { content: Json ## Alternatives considered -- **Return rendered text to Code Mode:** rejected because callers would continue scraping prose for job ids, mount ids, paths, and structured provider results. +- **Return rendered text to PTC mode:** rejected because callers would continue scraping prose for job ids, mount ids, paths, and structured provider results. - **Persist canonical values on `tool/result`:** rejected because nested execution values are not model history, need not survive replay, and would create a session-format and storage commitment unrelated to Native reconstruction. - **Let tools return both value and content:** rejected because two author-owned results can disagree and policy cannot state which one is authoritative. The renderer makes presentation a deterministic projection of the validated value. - **Treat content replacement as value redaction:** rejected because presentation and programmatic access are different consumers; hiding only the former would create a false security boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index bbcd519de8..a78defb2b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:Code Mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 +工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:PTC mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 持久会话约定将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩(compaction)和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。 @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。 -规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;Code Mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 +规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 第一方工具在保持现有 Native 文本不变的同时返回领域 DTO: @@ -66,7 +66,7 @@ MCP 桥接层通过 `McpResult<{...}> = { content: JsonValue[]; structuredConten ## 备选方案 -- **向 Code Mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 job id、挂载 id、路径和结构化提供方结果。 +- **向 PTC mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 job id、挂载 id、路径和结构化提供方结果。 - **在 `tool/result` 上持久化规范值:**不予采纳。嵌套执行值不属于模型历史记录,无需在回放后继续存在;持久化还会引入与 Native 重建无关的会话格式和存储承诺。 - **允许工具同时返回值和内容:**不予采纳。由作者分别维护的两份结果可能互相矛盾,策略也无法说明哪一份才是权威结果。渲染器会根据已校验值确定性地产生展示。 - **将内容替换视为值脱敏:**不予采纳。展示内容和程序化访问面向不同消费方;只隐藏前者会制造虚假的安全边界。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index a717ec463e..82efeeb49e 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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 .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 0fe4e86410f3b313ec5a31099d5a6ed1f828585b -2026-07-23-client-plugin-loading-model.zh.md: 386b0edb722d8cedd9325c941f9b392b8cdc8ae2 +2026-07-23-client-plugin-loading-model.md: 21bad78792c6b5aad48b51f454f6c08c0400ad72 +2026-07-23-client-plugin-loading-model.zh.md: 2758f28f3bd34131ece3bed74152fbfe0b36174e diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 0fe4e86410..21bad78792 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -53,8 +53,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). -2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. -3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. @@ -98,7 +98,7 @@ One governance implementation runs on both sides of the wire; the browser-specif Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, generated one-resource responses, current startup combo responses, and one previous startup generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition. -Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half. +Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 386b0edb72..2758f28f3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -53,8 +53,8 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro **host 侧——组合这张图。** 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。 -2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。 -3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。 +3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 @@ -98,7 +98,7 @@ Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。 -名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。 +名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 983d460bb0..e935dd601a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 3d1ccc2a0f71411d496466288934d9038425e7cf -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c2409e128dfdbd8550bb7052a7e0f67a40fe1d1f +2026-07-24-web-config-tree-boot-and-transport-layering.md: c44f68bb65758ec4879da8bded60f27f2f11e0bc +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 80f385a0a76a427c8f604a0df76decf77d2e62b0 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 3d1ccc2a0f..c44f68bb65 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,9 +16,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Session Controller consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**Transport responsibilities have explicit owners.** `dsh-client-connection` owns the `/api` route, request and response envelopes, browser authentication, Host/Origin checks, exact Fetch route registration, and the shared Typert interceptor seat. `dsh-api-gateway` owns typed Remote dispatch and the multiplexed WebSocket. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. @@ -33,7 +33,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) | Rejected | One-line reason | |---|---| | Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | -| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | +| Runtime `assembly` shim plugin providing an `apiHandler` service | Connection already composes Remote interception and feature-owned exact Fetch routes into one handler at the transport edge | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform exports; the standard `./client` carries the whole browser half | | dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index c2409e128d..80f385a0a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,9 +16,9 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Session Controller 消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route 所有者;Web Worker 隧道传递 identity 字节。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输职责各有明确 owner。** `dsh-client-connection` 持有 `/api` 路由、请求与响应 envelope、浏览器认证、Host/Origin 检查、精确 Fetch 路由注册以及共享 Typert interceptor 席位。`dsh-api-gateway` 持有类型化 Remote 分发和多路复用 WebSocket。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route owner;Web Worker 隧道传递 identity 字节。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)持有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换)node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由持有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 @@ -33,7 +33,7 @@ Status: implemented | 弃案 | 一行理由 | |---|---| | 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | -| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关可自承载,且 `toFetchHandler` 是绑定方自己调的纯函数 | +| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | Connection 已在传输边缘把 Remote interception 与功能自有的精确 Fetch 路由组合成一个 handler | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口不统一;标准 `./client` 承载完整浏览器半 | | dev overlay / `cordis.dev.yml` | 一套 yml;`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 | 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 index 64e6bbc289..1548b145b4 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 6f159dc16e0e4063f9077c31829a10caca98eae0 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 2cb082dce75f1845e52a52289a8e3eaa1a000931 +2026-07-25-web-client-session-scope-and-provide-channel.md: 1fa442e8db2d8b2d2ec66730700c9c88dceddbae +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: ea9a6e247402e6a2d15fb4bfc0ebd6e65fc021df 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 index 6f159dc16e..1fa442e8db 100644 --- 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 @@ -107,7 +107,7 @@ Slot scope is the closed set `root | session-maybe | session`: - 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). The commands frame and its typed client event were later replaced by verbatim forwarding of `commands/change` through `ctx.remote.$on` ([forwarded Remote events](2026-08-10-remote-event-delivery.md)); `connection/reset` is unchanged, and the invalidation-not-diffing contract this bullet states still holds. -- `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). +- `command.list/execute` and `skills/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 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 index 2cb082dce7..ea9a6e2474 100644 --- 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 @@ -107,7 +107,7 @@ slot scope 是闭集 `root | session-maybe | session`: - summary `blank` 列与 `host/session-added` 帧 `blank` 字段(见上文 blank 位)。 - SSE(Server-Sent Events)帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为陈旧)。 该 commands 帧及其类型化 client 事件后来被「`commands/change` 经 `ctx.remote.$on` 原样转发」取代([转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md));`connection/reset` 不变;本条陈述的「失效而非差分」契约依然成立。 -- `command.list/execute`、`skill.list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的恢复语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)。 +- `command.list/execute`、`skills/list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的恢复语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)。 - `session.create` 请求形状:workspaceId/cwd 二选一 + 可选调用方预分配 sessionId(同 id 同 cwd 重试幂等,异 cwd 报 `session-conflict`)。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index 9bbd5cdb6a..c04c301afa 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.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 .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md -2026-07-25-web-command-surfaces-and-assembly.md: a3628a7d99a6ab33652c67f6188933dea213194b -2026-07-25-web-command-surfaces-and-assembly.zh.md: eef93d750c502f5c034b6604774de31d75d6d342 +2026-07-25-web-command-surfaces-and-assembly.md: 943b68416d896c674783156b05997840c6df3255 +2026-07-25-web-command-surfaces-and-assembly.zh.md: c35d47202894af99377932df57e5de118186c1ce 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 index a3628a7d99..943b68416d 100644 --- 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 @@ -28,7 +28,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### 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, the plain-text-reference decision); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. 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-skill**: `skills/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, the plain-text-reference decision); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. 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 and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream). ### Fixture command routing and assembly 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 index eef93d750c..c35d472028 100644 --- 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 @@ -28,7 +28,7 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) -- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,纯文本引用决策);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通提示词走(命令平面之外;tool-skill 不变,会话前缀目录提供协作关联)。 +- **ui-skill**:`skills/list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,纯文本引用决策);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通提示词走(命令平面之外;tool-skill 不变,会话前缀目录提供协作关联)。 - **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 5d8fa99508..d4ca06ac71 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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 .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 4401dc8e361281b1239eb84319fc5353948bc217 -2026-07-28-api-browser-trust-boundary.zh.md: c1db2632a6019585ab6a948bcb0f4f2ef853b8a7 +2026-07-28-api-browser-trust-boundary.md: 397c93e084f84579ce3f90654f514428e696ffcd +2026-07-28-api-browser-trust-boundary.zh.md: 6d3896fd2c1fd6c607b9d92376eee76ab03df3c7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 4401dc8e36..397c93e084 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -12,7 +12,7 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080 Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves: -- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. +- **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. - **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index c1db2632a6..6d3896fd2c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -12,7 +12,7 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分: -- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 - **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index a72957b18a..d09e7e13cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 423fec3ad517e645f1cdee3513bad312a56989a8 -2026-07-28-directory-picker-capability-seam.zh.md: f652157fc152dee44a50ab8b55cc6120d92a1a26 +2026-07-28-directory-picker-capability-seam.md: 10ab8e393d5d33da6a09af5caa4024f02c57165c +2026-07-28-directory-picker-capability-seam.zh.md: 8764dc208bef1ead3fc1ef608c4d3b2b37400602 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 423fec3ad5..10ab8e393d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -10,9 +10,9 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p ## Decision -A three-package capability seam in `packages/host/` — `directory-picker` (Service Definition), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. +A three-package capability seam in `packages/host/` — `directory-picker` (Service Definition), `directory-picker-native`, `directory-picker-browse` (backends) — has one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. `DirectoryPickerController` in `dsh-api-workspace-controller` injects `directoryPicker`, serves the matching generated Remote methods, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `directoryPicker/pick`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: @@ -31,7 +31,7 @@ Placement and policy rulings folded into this decision: - **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. - **One uniform Service Definition method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. -- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. +- **Direct stdlib calls inside the API adapter (no seam).** Rejected: keeps the adapter the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. @@ -43,6 +43,6 @@ Placement and policy rulings folded into this decision: ## Consequences - `cordis.yml` chooses the interaction; `apps/cli` mounts the [`-auto` chooser](../feature/2026-07-29-directory-picker-adaptive-default.md), which resolves the host's situation at boot and mounts `-native` or `-browse` itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction. -- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. +- The wire exposes generated `directoryPicker/list` and `directoryPicker/createDirectory` methods with four error codes; the Connection fixture serves a deterministic browse tree and `directoryPicker/pick` result for keyless assembled tests. - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index f652157fc1..8764dc208b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -10,9 +10,9 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. ## 决策 -在 `packages/host/` 落一个三包能力 seam——`directory-picker`(Service Definition)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一约定方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(Service Definition)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一约定方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。`dsh-api-workspace-controller` 中的 `DirectoryPickerController` 注入 `directoryPicker`,提供匹配的生成 Remote 方法,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 -**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是**双面包**:浏览器一侧把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是**双面包**:浏览器一侧把匹配的交互注册进两个洞——`-native` 是驱动 `directoryPicker/pick` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: @@ -31,7 +31,7 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. - **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举约定(hidden 标志、面包屑、home 锚点)不属于存储 seam。 - **统一的 Service Definition 方法集(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而原生选择器实现不了原语。交互差异不可约,故用判别标签。 -- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture(测试前置数据)/测试后端,与促成这项工作的插件教义相悖。 +- **API adapter 里直接调标准库(不建 seam)。** 否决:换装点仍是改 adapter 源码,失去 fixture(测试前置数据)/测试后端,与促成这项工作的插件教义相悖。 - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的「显示隐藏」开关(「隐藏隐藏文件」)。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 @@ -43,6 +43,6 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. ## 后果 - `cordis.yml` 决定交互形态;`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.zh.md),它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。 -- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 +- 协议公开生成的 `directoryPicker/list` 与 `directoryPicker/createDirectory` 方法及四个错误码;Connection fixture 提供确定性浏览树与 `directoryPicker/pick` 结果供无密钥组装测试使用。 - 未来的新交互(或提供 `native` 交互的 Electron 提供方)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 35802e4e80..8baf8386d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.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 .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md -2026-07-29-projected-token-usage-and-request-context.md: 063f2300f378f6f7763bce87b11add5da3093230 -2026-07-29-projected-token-usage-and-request-context.zh.md: 37b8741d09e9ec56f6b9f273e05460b2deb4f6f9 +2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6 +2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index 063f2300f3..75a05e5a0e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -40,7 +40,7 @@ The non-atomicity is deliberate, not a defect. A consumer that genuinely needs a **An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected).** An earlier revision emitted `session/model-request`: one non-replayable frame carrying `contextTokens` and `contextWindow` measured at the same `agent/model-request` boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after `host/session-removed` and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. `session/subscribed` is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and `lastSeq` is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison. -That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. +That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made the transport adapter a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. **Fold the loaded node window in React.** Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics. @@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. -Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. +Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 37b8741d09..7cce5989d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -40,7 +40,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节 **以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。** 较早的一个修订版会发出 `session/model-request`:一个不可回放的帧,携带在同一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的,是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSE(Server-Sent Events)流,彼此之间没有顺序保证:在移除之前发出的请求可能在 `host/session-removed` 之后才到达,让一个已死会话的遥测数据复活;而复用同一 id 的新生命周期的合法请求,又可能被一条迟到的移除拦下。`session/subscribed` 不能证明生命周期:它只说明某个队列开始订阅某个 id,而不说明新的内存会话替换了较早的会话;`lastSeq` 则是两个生命周期可以共用的持久水位线。正确的修法需要在帧上、订阅上和移除上都带一个单调递增的生命周期代次,再加上一次客户端水位线比较。 -这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 +这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把传输适配器变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 **在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。 @@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定, 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。 -每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 +每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 6e02fa3915..16238db465 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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 .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: c817071ed17554d06249aa6893ed759bea5d72d0 -2026-07-30-web-config-plane.zh.md: 0b15e329340051d0f63e8a5b1f8c70a29a2138d2 +2026-07-30-web-config-plane.md: 81b501db529bf1b2974fd4541045991c5a8bf087 +2026-07-30-web-config-plane.zh.md: 3f02a17e4826bb35ecfd45da25c4b0170be270cb diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index c817071ed1..81b501db52 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -12,11 +12,11 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Decision -**Configuration calls use their owning wire implementation, rejections as codes, and owner events forwarded verbatim.** `@deepseek-ai/dsh-api-settings-controller` owns generated Remote methods for `settings/describe`, `settings/update`, `settings/replace`, `settings/mutate`, and `credentials/describe|set|unset`; `settings.openDocument` and the `llm.*` methods remain in `RpcMethodMap`. Provider absence retains the configuration API's actionable `internal` diagnostic, while seam rejections retain `settings-rejected {ns}` / `settings-conflict {ns, expected, actual}` / `credential-rejected {ref}`. Clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates generated Remote methods and API Proxy fallbacks with the same browser session; Host/Origin failures still return 403 before identity is checked. +**Configuration calls use their owning wire implementation, rejections as codes, and owner events forwarded verbatim.** `@deepseek-ai/dsh-api-settings-controller` owns generated Remote methods for `settings/describe`, `settings/update`, `settings/replace`, `settings/mutate`, `settings/openSettingsDocument`, `settings/openAgentPresetDirectory`, and `credentials/describe|set|unset`; `@deepseek-ai/dsh-llm` owns `llm/listProviders`, `llm/listConfigurableProviders`, and `llm/discoverModels`. Provider absence retains the configuration API's actionable `internal` diagnostic, while seam rejections retain `settings-rejected {ns}` / `settings-conflict {ns, expected, actual}` / `credential-rejected {ref}`. Clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates generated Remote methods and API Proxy fallbacks with the same browser session; Host/Origin failures still return 403 before identity is checked. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. -**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The browser-authenticated `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; non-loopback pages retain the Client policy that makes no Host settings read for this action. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The browser-authenticated `settings/describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings/openSettingsDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; non-loopback pages retain the Client policy that makes no Host settings read for this action. **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. @@ -32,7 +32,7 @@ The request-level configuration seam made LLM adapter configuration restart-free - **Storing the typed key as a literal `apiKey` setting** — the single API key input requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as in the request-level seam note: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. -- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-file.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. +- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `session/openWorkspacePath` in the browser** — rejected because `settings-file.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 0b15e32934..3f02a17e48 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -**配置调用使用其所属的 wire 实现,拒绝落为错误码,owner 事件原样转发。**`@deepseek-ai/dsh-api-settings-controller` 持有 `settings/describe`、`settings/update`、`settings/replace`、`settings/mutate` 与 `credentials/describe|set|unset` 的生成 Remote 方法;`settings.openDocument` 和 `llm.*` 方法仍位于 `RpcMethodMap`。provider 缺失时保留配置 API 可操作的 `internal` 诊断,seam 拒绝则保留 `settings-rejected {ns}`/`settings-conflict {ns, expected, actual}`/`credential-rejected {ref}`。Client 订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 使用同一个浏览器会话认证生成的 Remote 方法与 API Proxy 回退;Host/Origin 失败仍会在身份校验前返回 403。 +**配置调用使用其所属的 wire 实现,拒绝落为错误码,owner 事件原样转发。**`@deepseek-ai/dsh-api-settings-controller` 持有 `settings/describe`、`settings/update`、`settings/replace`、`settings/mutate`、`settings/openSettingsDocument`、`settings/openAgentPresetDirectory` 与 `credentials/describe|set|unset` 的生成 Remote 方法;`@deepseek-ai/dsh-llm` 持有 `llm/listProviders`、`llm/listConfigurableProviders` 与 `llm/discoverModels`。provider 缺失时保留配置 API 可操作的 `internal` 诊断,seam 拒绝则保留 `settings-rejected {ns}`/`settings-conflict {ns, expected, actual}`/`credential-rejected {ref}`。Client 订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 使用同一个浏览器会话认证生成的 Remote 方法与 API Proxy 回退;Host/Origin 失败仍会在身份校验前返回 403。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 -**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。经浏览器认证的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;非 loopback 页面保留 Client 策略,不为这项操作发起 Host settings 读取。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。经浏览器认证的 `settings/describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由 provider 持有的本地文档后才显示,并调用无路径参数的 `settings/openSettingsDocument`;Host 会在文本文档交接前再次解析 provider 路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;非 loopback 页面保留 Client 策略,不为这项操作发起 Host settings 读取。 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 @@ -32,7 +32,7 @@ Status: implemented - **把键入的密钥存成字面 `apiKey` 设置**——单个 API 密钥输入框的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与请求级 seam note 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧,每个只需增加一种形状,就能让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 -- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-file.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 +- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `session/openWorkspacePath` 回传 `documentPath`**——否决,因为 `settings-file.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml index 7d56d900be..dd6e455025 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.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 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md -2026-07-31-code-runtime-portable-identifier-seam.md: 6d75f0b0872a5d4b92403f597f5551276e2d7c9f -2026-07-31-code-runtime-portable-identifier-seam.zh.md: 98f4665f9c9964701cdeb108e411ae08121af9e0 +2026-07-31-code-runtime-portable-identifier-seam.md: 2011b0f6bc8209e628227ddf486aa1143a63688a +2026-07-31-code-runtime-portable-identifier-seam.zh.md: 36af33366d004fedc6b1077a937d6519de743638 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md index 6d75f0b087..2011b0f6bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -6,7 +6,7 @@ English | [中文](2026-07-31-code-runtime-portable-identifier-seam.zh.md) ## Problem -The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a Code Mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker-thread`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract. +The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a PTC mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker-thread`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract. A second backend written against a different language (CPython) would either re-declare its own rules — letting `lambda` pass the worker and fail Python, or `$tools` pass the worker and fail every non-JS backend — or import the worker's, inverting the dependency so a Service Provider reached into a sibling Service Provider. Neither keeps the portability promise real: it would hold only for the backend a caller happened to test against. @@ -25,7 +25,7 @@ The constants live in the Service Definition even though the worker is the only ## Scope -This decision delivers only the Service Definition extension and the worker's adoption of it. The `py-types` renderer and Code Mode language dispatch are owned by the [language-dispatch note](../feature/2026-07-31-code-mode-language-dispatch.md); a Python backend does not exist yet. The Service Definition README keeps its worker-only wording for that reason: linking to a `dsh-code-runtime-python` README that does not exist would break the dead-link gate. +This decision delivers only the Service Definition extension and the worker's adoption of it. The `py-types` renderer and PTC mode language dispatch are owned by the [language-dispatch note](../feature/2026-07-31-ptc-language-dispatch.md); a Python backend does not exist yet. The Service Definition README keeps its worker-only wording for that reason: linking to a `dsh-code-runtime-python` README that does not exist would break the dead-link gate. `RESERVED_BINDING_GLOBALS` encodes the Python bootstrap's concrete design ahead of the backend itself: it seeds exactly `__builtins__`/`__name__` and wraps the program under `__dsh_main__`. A Python backend that seeds any additional module global (`__doc__`, `__loader__`, `__spec__`, `__file__`, `__package__`, …) MUST widen this set in the same change, exactly as adding a language widens `PORTABLE_RESERVED_WORDS` — a name the bootstrap seeds but the set omits is the portability split this contract exists to prevent. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md index 98f4665f9c..36af33366d 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 Code Mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker-thread` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$` 的 `IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性约定。 +code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 PTC mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker-thread` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$` 的 `IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性约定。 一个针对不同语言(CPython)编写的第二后端,要么重新声明自己的规则——让 `lambda` 通过 worker 却在 Python 上失败,或让 `$tools` 通过 worker 却在每个非 JS 后端上失败——要么导入 worker 的规则,从而反转依赖,使一个 Service Provider 伸手进入另一个兄弟 Service Provider。二者都无法让可移植承诺成真:它只对调用方恰好测试过的那个后端成立。 @@ -25,7 +25,7 @@ Service Definition 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0 ## Scope -本决策只交付 Service Definition 扩展与 worker 对它的采用。`py-types` 渲染器与 Code Mode 的语言分发归[语言分发 note](../feature/2026-07-31-code-mode-language-dispatch.zh.md) 所有;Python 后端尚不存在。Service Definition README 因此保留仅描述 worker 的措辞:链接到一个不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 +本决策只交付 Service Definition 扩展与 worker 对它的采用。`py-types` 渲染器与 PTC mode 的语言分发归[语言分发 note](../feature/2026-07-31-ptc-language-dispatch.zh.md) 所有;Python 后端尚不存在。Service Definition README 因此保留仅描述 worker 的措辞:链接到一个不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 `RESERVED_BINDING_GLOBALS` 先于后端本身编码了 Python bootstrap 的具体设计:它恰好 seed `__builtins__`/`__name__`,并把程序包装在 `__dsh_main__` 之下。任何 seed 额外模块 global(`__doc__`、`__loader__`、`__spec__`、`__file__`、`__package__` 等)的 Python 后端必须在同一改动中扩宽此集合,正如新增一门语言即扩宽 `PORTABLE_RESERVED_WORDS`——bootstrap 会 seed 却不在集合中的名称,正是本约定要防止的可移植性分裂。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 21995b0838..ad1a5c6eb1 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 97352a0e3376ce1fe26bac62b88c2c674202adc7 -2026-08-03-per-session-agent-presets.zh.md: b62baf3e3097ba99247c2e86cc3fb5b7e43e2fab +2026-08-03-per-session-agent-presets.md: 9d5fffbd4d69713fe733235cc0352bc93c9ce55c +2026-08-03-per-session-agent-presets.zh.md: 406d546828489ccd172205cde7d4b5e0ba96a39b diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 97352a0e33..9d5fffbd4d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -55,9 +55,9 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Connection authenticates authoring, `list`, `select`, and the complete Host API with one browser session: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability, while choosing a preset grants nothing `session.create` with `agentPreset` did not already grant. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `SubagentRuntime` is a Host row that exposes the browser's cross-session Remote queries (`list`, `prompt`), so it waited forever for a service only sessions provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. -**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. +**A real-composition test that disables a host row cannot audit that row.** The web composition test keeps API Gateway and the domain Remote services active while disabling the transport-only webserver, Connection, Session export, asset, and telemetry rows. With the browse directory picker substituted, its startup audit covers the host-plane service graph without binding a port. **A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. @@ -67,7 +67,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. -**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `dsh-host-apiproxy` discards the `AgentHandle` it creates, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. +**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. - Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index b62baf3e30..406d546828 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -56,9 +56,9 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。Connection 用一个浏览器会话认证创作操作、`list`、`select` 与完整 Host API:组装指明一个会话所运行的插件,因此读取它是侦察,写入它是任意能力;选择 preset 则没有授予 `session.create` 携带 `agentPreset` 时尚未拥有的能力。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`SubagentRuntime` 是 Host 行,它公开浏览器的跨会话 Remote 查询(`list`、`prompt`),因而永远等待一个只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 -**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 +**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试保持 API Gateway 与各业务 Remote 服务启用,同时禁用只承载传输的 webserver、Connection、Session export、资源与遥测行。替换为 browse 目录选择器后,其启动审计无需绑定端口即可覆盖 Host 平面的服务图。 **preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 @@ -68,7 +68,7 @@ Status: implemented **这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 -**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`dsh-host-apiproxy` 创建后直接丢弃 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 +**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 - 遗留 TODO:idle agent 驱逐——会话持久化后 dispose,恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 9647bf2513..26dce064e4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 0132dba5888faa1b9e6a4e59b45ee56a259eeef7 -2026-08-04-draft-provider-endpoint-interrogation.zh.md: 8cea5d2809611696606113b2e38578f1b54b4e09 +2026-08-04-draft-provider-endpoint-interrogation.md: a93c5f79a0e4f824be2f1e96853204d27cd22845 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: f8e9002b5267a9d13425601eaefff29e45662699 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 0132dba588..a93c5f79a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -47,4 +47,4 @@ What it costs: the wire gained a third secret-carrying payload, so the configura ## Testing -`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/api/settings-controller/tests/` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index 8cea5d2809..f8e9002b52 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -47,4 +47,4 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 ## Testing -`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/api/settings-controller/tests/` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 2a37960848..10370530e9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-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 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 12069d129227cce13eb5f9f39e636921d4bf9efa -2026-08-09-client-conversation-node-assembly.zh.md: 957c2b291293761ff2417f60093b7962bb75bbdf +2026-08-09-client-conversation-node-assembly.md: e37f2ab3bdf9f3943cb1bcd193d5ea578a0b2c19 +2026-08-09-client-conversation-node-assembly.zh.md: be23b321b710f2f56e3e7af870ba554adb355cef diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 12069d1292..e37f2ab3bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -359,7 +359,7 @@ Conversation tests cover every built-in Chat Definition, Assistant Step data, Tu Slot type/runtime tests pin required parent-provided common inject, the `hookContext` type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory. -Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and Code Mode root + nested subcalls against master layout. +Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and PTC mode root + nested subcalls against master layout. History-path tests cover complete replace, non-overlapping prepend, complete-range deduplication, partial-overlap rejection, empty-page `hasMore` convergence, and scalar live append. Scalar and packed representations of the same Assistant history produce equal Chat and Trajectory State, timing boundaries, and final Nodes; one packed run remains one Match through replace, prepend, Location replay, and registry rebuild. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 957c2b2912..be23b321b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -359,7 +359,7 @@ Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Tu Slot type/runtime tests 固定父注册必须提供声明的 common inject、`hookContext` 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。 -Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 Code Mode root + nested subcalls 与 master 的布局。 +Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 PTC mode root + nested subcalls 与 master 的布局。 历史链路验证同时覆盖完整 replace、非重叠 prepend、完整 range 去重、部分重叠拒绝、空页 `hasMore` 收敛和 scalar live append。相同 Assistant 历史的 scalar 与 packed 表示产生相同 Chat/Trajectory State、timing boundary 与最终 Node;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 12f13004b8..13317f8219 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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 .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 9c17b8d418924c38174b4d958fd54b057b118019 -2026-08-09-headless-direct-core-entry-point.zh.md: d95978a832d52b26b1139cabb4b23ade93ce0da3 +2026-08-09-headless-direct-core-entry-point.md: ca3effd6c054ba70bb2f5d4db0794386c37cbcab +2026-08-09-headless-direct-core-entry-point.zh.md: 4da5c9539358dd275bcde18c23907c31d35d2519 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 9c17b8d418..ca3effd6c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -6,21 +6,21 @@ English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem -The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. +The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, browser RPC, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision -The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. +The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the PTC mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no browser Connection, HTTP server, Web runtime, or browser client. PTC mode and Session persistence are one-shot Agent capabilities independent of Web presentation. `headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. [Headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns the live stderr projection; a terminal `error` reason writes its durable code and message there, and unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct creation and Session Controller Remote calls consume this service; the Session Controller owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification @@ -31,14 +31,14 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | -| Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | +| Build a Host-only one-shot bundle around browser RPC | A local one-shot entry point has no client boundary. | +| Use the in-process Connection carrier for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | -| Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | +| Omit PTC mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | | Normalize every tuple containing Web and headless bundles | Bundle lists are an extension surface; only the exact installation-owned tuple is safe to classify. | ## Consequences -`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no browser request id. -ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. +Connection carrier coverage stays in the Connection package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index d95978a832..4da5c95393 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -6,21 +6,21 @@ Status: implemented ## 问题 -`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 +`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、浏览器 RPC、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 -随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 +随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 PTC mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含浏览器 Connection、HTTP server、Web 运行时或浏览器客户端。PTC mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。[Headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责实时 stderr 投影;结束原因为 `error` 时,其持久化错误码与消息写入 stderr,驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接创建与 Session Controller Remote 调用均消费该服务;Session Controller 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.zh.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。 ## 验证 @@ -31,14 +31,14 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | -| 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | +| 围绕浏览器 RPC 构建纯 Host 一次性组合包 | 本地一次性入口没有客户端边界。 | +| 使用进程内 Connection carrier 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | -| 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | +| 省略 PTC mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | | 规范化所有包含 Web 与 headless 组合包的元组 | 组合包列表是扩展面;只有精确的安装过程所属元组可以安全分类。 | ## 后果 -`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带浏览器 request id。 -ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 +Connection carrier 覆盖保留在 Connection 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 33a31a1363..a1f574e789 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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 .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 5e6e04bdf2c6b685bbf10f05ede9c96ea8104429 -2026-08-10-remote-event-delivery.zh.md: d744b92d47f5778397b519fa09d4b91f620dcd7e +2026-08-10-remote-event-delivery.md: 4b9c2f224e36fb97f798492c999726eb55bad214 +2026-08-10-remote-event-delivery.zh.md: 4ca1f8c244df4e985d1223b6c468b450be943abf diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 5e6e04bdf2..4b9c2f224e 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -70,9 +70,9 @@ $on(event: Event, listener: TypertClientEventLi **The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event. -Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners. +Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready', clientId, host: { home } }` before iterating the event source. `ConnectionController` publishes `connected` only after that item arrives, so baseline reads cannot race ahead of incremental listeners. -A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. +A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation and reopens `$events` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. The Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events. @@ -132,13 +132,13 @@ cancel { type, eventId } The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events. -The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. +The Client requires an opening `ready` item with a non-empty `clientId` and `host.home`; every later item is checked for exact fields by discriminant. The ready item establishes the Connection generation and supplies the stable Host path-display fact. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. `$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only. ### The `apps/web` browser e2e belongs to the Host face -The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces 21 errors because one program cannot hold both faces' merges for the same Context key. +The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.connection`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces errors because one program cannot hold both faces' merges for the same Context key. This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc. @@ -153,11 +153,10 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp | `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions | | Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files | -| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation | | `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source | | `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established | | Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` | -| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source | +| `client/connection` | Provides the one generation-source registration point; `ConnectionController` publishes the Host facts from `$events` ready, and the fixture emits events from the same source | | `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` | ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index d744b92d47..4ca1f8c244 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -70,9 +70,9 @@ $on(event: Event, listener: TypertClientEventLi **契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。 -每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。 +每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready', clientId, host: { home } }`,再开始迭代事件 source。`ConnectionController` 只有在该项到达后才发布 `connected`,因此 baseline 读取不会跑在增量 listener 前面。 -物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 +物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation,在退避后重开 `$events`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 Client 以 Remote 实例私有 Cordis key 分发。普通 `emit` 使用 `parallel()` 并隔离 listener 失败;Agent-scoped `waterfall` 在解析出的 Agent Context 上使用 `waterfall()`,允许结果、拒绝或 `next()` 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。 @@ -132,13 +132,13 @@ cancel { type, eventId } Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。 -Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discriminant 精确校验字段。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 +Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 item 按 discriminant 精确校验字段。ready 项建立 Connection generation,并提供稳定的 Host 路径显示信息。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 `$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。 ### apps/web 的 browser e2e 属于 Host 面 -`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接摸 `ctx.apiProxy`、host `SessionStore.get/create/flush`、`ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 client 程序**——把它们搬进 client 聚合会立刻报 21 条错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 +`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接访问 `ctx.connection`、Host `SessionStore.get/create/flush` 与 `ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 Client 程序**——把它们搬进 Client 聚合会报错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 由此得到一条对本设计要紧的连带纪律:**这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图**。`ui-settings-general`/`ui-settings-models`/`ui-permission`/`ui-commands` 四个消费者 references `api/remotes` 的 client face,而该 face 必须等 host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → `goal/remote` → host tsdown → 排在 host tsc 之后。 @@ -153,11 +153,10 @@ Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discrim | `api/remotes` | `src/remote-events.ts`(带 mode 的名单值)与 `src/types.ts`(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation | | `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 | | `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` | | 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` | -| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 | +| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 发布 `$events` ready 携带的 Host 信息,fixture 也从同一 source 产生事件 | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | ## 备选方案 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml new file mode 100644 index 0000000000..5868c44f02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.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/architecture/2026-08-10-unary-apiproxy-remote-migration.md +2026-08-10-unary-apiproxy-remote-migration.md: b98c7ee95b61ec00a5cab3106e812a6f17fc0a15 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 74bca8fd72f3e41075f5a44eba116fe127343bb2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md new file mode 100644 index 0000000000..b98c7ee95b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -0,0 +1,65 @@ +# Agent Note: Place unary browser operations on owning Remote services + +Status: implemented + +English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md) + +## Problem + +The Host API Proxy duplicated simple unary operations across business Services, API Proxy interfaces, Zod schemas, route tables, client stubs, and Client callers. [Typert Remote calls](2026-08-02-typert-remote-method-calls.md) already let a business package own this class of call, but moving an endpoint without its lifecycle and projection policy could change observable behavior. + +Agent-bound calls require particular care. Shared lookup policy reuses live Agents, resumes ordinary cold Sessions with their recorded presets, deduplicates concurrent resumes, and rejects subagent-owned identities. Skill listing instead must inspect a Session without activating its Agent. Settings and preset operations keep their Host-owned document paths out of browser requests; Session file links preserve their caller-resolved path behavior. + +## Decision + +Simple unary operations live on their natural business Remote owner. The business package owns the Remote signature and Host adaptation; `@deepseek-ai/dsh-api-remotes/client` selects its generated contribution; the Client package owns presentation joins. Connection owns the transport envelope and exact Fetch route registry, and no API Proxy service remains. + +| Former API Proxy operation | Destination | Owner and preserved behavior | +|---|---|---| +| `session.rename` | `sessionTitle/rename` | `SessionTitleService` resolves the Session through the shared lookup policy and returns the title event sequence. | +| `command.list`, `command.execute` | `commands/list`, `commands/execute` | `CommandRuntime` preserves Agent lookup, unmatched commands, and caller cancellation. | +| `llm.providers` | `llm/listProviders`, `llm/listConfigurableProviders` | `LlmRuntime` owns provider facts; Clients join live and configurable rows. | +| `llm.discoverModels` | `llm/discoverModels` | `LlmRuntime` preserves provider discovery, cancellation, and sanitized failures. | +| `llm.models` | `session/modelCatalog` | `SessionController` owns the Host-generation catalog, default selection, and isolated provider failures. | +| `credentials.describe`, `credentials.set`, `credentials.unset` | `credentials/describe`, `credentials/set`, `credentials/unset` | `CredentialsController` preserves reference validation, field projection, provider diagnostics, and refusal mapping. | +| `settings.describe`, `settings.update`, `settings.replace`, `settings.mutate` | Equivalent `settings/*` methods | `SettingsController` preserves redaction, mutation semantics, revision checks, and provider failures. | +| `settings.openDocument` | `settings/openSettingsDocument` | `SettingsController` prepares the provider-owned document and opens it with text-editor intent. | +| `agentPreset.read`, `agentPreset.copy`, `agentPreset.remove` | Equivalent `agentPresets/*` methods | `AgentPresetService` owns document reads, copies, and removals. | +| `agentPreset.openDocument` | `settings/openAgentPresetDirectory` | `SettingsController` resolves the preset directory and returns its path when native opening is unavailable. | +| `subagent.interrupt` | `subagents/interruptByParent` | The subagent service preserves parent authority without activating either Agent. | +| `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | Equivalent `workspace/*` methods | The Workspace registry owns detached snapshots and serialized mutations. | +| `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | +| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | +| `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. | +| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | +| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` registers an exact Connection Fetch route and streams the ZIP without a JSON Remote envelope. | + +The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. + +The native path implementation lives in `@deepseek-ai/dsh-native-command`. Settings controllers select Host-owned targets, while Session-aware Clients resolve workspace paths before calling `SessionController`; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. + +## Browser authentication + +Connection authenticates the complete `/api` request before choosing a Typert endpoint or exact Fetch route. Remote calls and Session-log downloads therefore require the same browser session and Host/Origin checks. + +## Verification + +Focused Host and Client tests cover Remote calls, lookup and no-activation policy, native opening, error projection, and removal of legacy routes. The repository build generates and consumes the selected Remote contributions before building the Web application. + +## Alternatives considered + +**Keep simple calls in the API Proxy.** Rejected because it preserves duplicate interfaces, schemas, route rows, stubs, and result projections after a business owner exists. + +**Keep `host.describe`.** Rejected because one bootstrap call coupled Connection readiness to unrelated process and business facts. The generation-ready frame carries the only lifecycle fact needed immediately, and capability-owning pages query their domains when shown. + +**Publish every business capability in the generation-ready frame.** Rejected because those values have no common update lifecycle. Only the stable Host home belongs to Connection; each business owner answers its own current capability. + +**Represent Session export as a Remote.** Rejected because the browser download manager consumes a streamed HTTP response rather than a JSON result. An exact registered Fetch route keeps ownership in the feature package without adding a second gateway. + +**Put native opening in one controller.** Rejected because Session and Settings select different authorized targets. A Host utility avoids controller-to-controller imports without making the browser authoritative for filesystem targets. + +## Consequences + +Business owners and Client consumers each define one side of a unary operation, while Connection owns authentication, transport, response envelopes, exact Fetch routes, and generation state. Removing the legacy client timeout is the accepted observable transport change; business results, cancellation, lifecycle policy, filtering, and native-path authority remain owned by their existing domains. + +Generated Remote artifacts and the explicit API Remotes assembly become required whenever a Remote signature or selected package changes. diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md new file mode 100644 index 0000000000..74bca8fd72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 将一元浏览器操作放到所属 Remote 服务 + +Status: implemented + +[English](2026-08-10-unary-apiproxy-remote-migration.md) | 中文 + +## 问题 + +Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由表、Client stub 与 Client 调用方之间重复定义简单一元操作。[Typert Remote 调用](2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包持有这类调用,但如果迁移 endpoint 时没有一并保留生命周期与投影策略,就会改变可观察行为。 + +与 Agent 绑定的调用需要格外谨慎。共享 lookup 策略会复用 live Agent、用记录的 preset 恢复普通冷 Session、对并发恢复去重,并拒绝由 subagent 持有的 identity。skill 列表则必须检查 Session 而不激活 Agent。Settings 与 preset 操作不会把 Host 持有的文档路径放进浏览器请求;Session 文件链接保留由调用方解析路径的行为。 + +## 决策 + +简单一元操作归属其自然的业务 Remote owner。业务包持有 Remote 签名与 Host 适配;`@deepseek-ai/dsh-api-remotes/client` 选择其生成贡献;Client 包持有呈现联接。Connection 持有传输 envelope 与精确 Fetch 路由注册表,不再存在 API Proxy 服务。 + +| 原 API Proxy 操作 | 目标 | Owner 与保留行为 | +|---|---|---| +| `session.rename` | `sessionTitle/rename` | `SessionTitleService` 通过共享 lookup 策略解析 Session,并返回标题事件序号。 | +| `command.list`、`command.execute` | `commands/list`、`commands/execute` | `CommandRuntime` 保留 Agent lookup、未匹配命令与调用方取消。 | +| `llm.providers` | `llm/listProviders`、`llm/listConfigurableProviders` | `LlmRuntime` 持有 provider 事实;Client 联接 live 与 configurable 行。 | +| `llm.discoverModels` | `llm/discoverModels` | `LlmRuntime` 保留 provider 发现、取消与净化后的失败。 | +| `llm.models` | `session/modelCatalog` | `SessionController` 持有 Host generation 的目录、默认选择与隔离后的 provider 失败。 | +| `credentials.describe`、`credentials.set`、`credentials.unset` | `credentials/describe`、`credentials/set`、`credentials/unset` | `CredentialsController` 保留引用校验、字段投影、provider 诊断与拒绝映射。 | +| `settings.describe`、`settings.update`、`settings.replace`、`settings.mutate` | 对应的 `settings/*` 方法 | `SettingsController` 保留脱敏、mutation 语义、revision 校验与 provider 失败。 | +| `settings.openDocument` | `settings/openSettingsDocument` | `SettingsController` 准备 provider 持有的文档,并按文本编辑器意图打开。 | +| `agentPreset.read`、`agentPreset.copy`、`agentPreset.remove` | 对应的 `agentPresets/*` 方法 | `AgentPresetService` 持有文档读取、复制与删除。 | +| `agentPreset.openDocument` | `settings/openAgentPresetDirectory` | `SettingsController` 解析 preset 目录,并在原生打开不可用时返回其路径。 | +| `subagent.interrupt` | `subagents/interruptByParent` | subagent 服务保留 parent 权限,且不激活任何一方的 Agent。 | +| `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | 对应的 `workspace/*` 方法 | Workspace registry 持有脱离可变对象的 snapshot 与串行 mutation。 | +| `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | +| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | +| `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 | +| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | +| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` 注册精确的 Connection Fetch 路由,并在没有 JSON Remote envelope 的情况下流式传输 ZIP。 | + +共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 + +原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Settings controller 选择 Host 持有的目标,Session-aware Client 则在调用 `SessionController` 前解析 workspace 路径;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 + +## 浏览器认证 + +Connection 在选择 Typert endpoint 或精确 Fetch 路由前认证完整的 `/api` 请求。因此 Remote 调用与 Session 日志下载要求相同的浏览器会话和 Host/Origin 校验。 + +## 验证 + +聚焦的 Host 与 Client 测试覆盖 Remote 调用、lookup 与不激活策略、原生打开、错误投影和 legacy 路由移除。仓库构建会先生成并消费所选 Remote contribution,再构建 Web 应用。 + +## 考虑过的替代方案 + +**将简单调用留在 API Proxy。** 否决,因为业务 owner 已存在后,这仍会保留重复的 interface、schema、路由行、stub 与结果投影。 + +**保留 `host.describe`。** 否决,因为一次 bootstrap 调用会把 Connection readiness 与互不相关的进程和业务事实耦合起来。generation-ready frame 只携带立即需要的生命周期事实,各 capability owner 页面在显示时查询自己的当前能力。 + +**在 generation-ready frame 中发布所有业务 capability。** 否决,因为这些值没有共同的更新生命周期。只有稳定的 Host home 属于 Connection;各业务 owner 回答自己的当前 capability。 + +**把 Session export 表示为 Remote。** 否决,因为浏览器下载管理器消费流式 HTTP 响应,而不是 JSON 结果。精确注册的 Fetch 路由让功能包持有该行为,同时不引入第二个 gateway。 + +**把原生打开操作放入某个 controller。** 否决,因为 Session 与 Settings 选择不同的授权目标。Host 工具可以避免 controller 间导入,同时不让浏览器成为文件系统目标的权威。 + +## 后果 + +业务 owner 与 Client consumer 各自定义一元操作的一侧,而 Connection 持有认证、传输、响应 envelope、精确 Fetch 路由与 generation 状态。删除 legacy Client timeout 是已接受的可观察传输变化;业务结果、取消、生命周期策略、过滤与原生路径权限仍由既有领域持有。 + +每当 Remote 签名或所选包发生变化,都必须更新生成的 Remote 产物和显式 API Remotes assembly。 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml index 7b2d89cd39..e482014ebc 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.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 .agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md -2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c -2026-08-17-settings-describe-mirror.zh.md: 1fad68ae6346d656cc7868121eac14fdf845f8dc +2026-08-17-settings-describe-mirror.md: 81be16c83e61f8c44b4903b4cc57e26d80fe065e +2026-08-17-settings-describe-mirror.zh.md: 6c37992b7de7c2c225bb2eb965f6f758b8be990e diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md index a3774699ff..81be16c83e 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md @@ -30,5 +30,5 @@ The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budge - Startup `settings.describe` went 15 → 2, and a new preference-owning plugin adds zero reads. - Every derived surface shows the same document revision at any moment; the per-reader guards (`refreshWelcomeIfLoaded`, `refreshPermissionIfLoaded`, `refreshDocumentIfLoaded`) and their subscriptions are gone. - The mirror refreshes on every document commit regardless of namespace, so an external settings edit now costs one background read even while no settings surface is open — the price of surfaces that open already fresh. The per-namespace `ns !== spec.namespace` filters are gone with the per-scope subscriptions. -- `credentials.describe` (3 startup calls), `agentPreset.list` (2), and `llm.providers` are separate sources and stay direct; the same mirror pattern fits them if they ever need it. +- `credentials/describe` (3 startup calls), `agentPresets/list` (2), `llm/listProviders`, and `llm/listConfigurableProviders` are separate sources and stay direct; the same mirror pattern fits them if they ever need it. - A new direct `settings.describe` caller in client code is a budget regression; the e2e's failure message says to grep for callers outside `ui-settings`. diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md index 1fad68ae63..6c37992b7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md @@ -30,5 +30,5 @@ Status: implemented - 启动期 `settings.describe` 从 15 次降到 2 次,新增持有偏好设置的插件带来零次新增读取。 - 任一时刻每个派生面看到的都是同一份文档 revision;各读取方的防护(`refreshWelcomeIfLoaded`、`refreshPermissionIfLoaded`、`refreshDocumentIfLoaded`)及其订阅随之消失。 - 镜像对任何命名空间的文档提交都会刷新,因此在没有任何设置表面打开时,一次外部设置编辑现在也花费一次后台读取——这是「表面打开即新鲜」的代价。随着各 scope 订阅的删除,按命名空间的 `ns !== spec.namespace` 过滤一并消失。 -- `credentials.describe`(启动 3 次)、`agentPreset.list`(2 次)与 `llm.providers` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。 +- `credentials/describe`(启动 3 次)、`agentPresets/list`(2 次)、`llm/listProviders` 与 `llm/listConfigurableProviders` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。 - 客户端代码中新增直连 `settings.describe` 调用即是预算回归;e2e 的失败信息会提示在 `ui-settings` 之外 grep 调用方。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 00652d3b09..a73eb1a62f 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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 .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362 -2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e +2026-08-18-session-history-and-event-transport.md: 8f26b2977dceeb2085bf270ae603cd21d48157f5 +2026-08-18-session-history-and-event-transport.zh.md: 10edeff16695cac265f2026b300eb206838a53e4 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 808565ff7d..8f26b2977d 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -62,21 +62,23 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. +The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries. + After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. The Gateway-internal `$events` logical stream is the sole generation source for `ConnectionHandle`. It does not depend on whether any business `$on` subscription exists, so connection health does not vary with the number of UI listeners. -The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready' }` with a `clientId`; this frame proves that the current generation can receive increments. +The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready', clientId, host: { home } }`; this frame proves that the current generation can receive increments and carries the stable Host path-display fact. -`ConnectionController` waits for `$events` readiness and `host.describe` in parallel. It publishes `connected` only after both complete, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. +`ConnectionController` publishes `connected` only after `$events` readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. -Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws `hostDescription`, then re-establishes `$events` and `host.describe` after backoff. +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` after backoff. Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. -Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. +Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. ### General Remote stream model @@ -134,7 +136,7 @@ If a page request is canceled with its physical carrier generation, the journal `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. -It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `llm.models` because it is not Session-specific. +It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. @@ -320,15 +322,17 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re **Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection. +**Send application-level JSON heartbeat frames.** This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics. + **Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently. **Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly. ## Verification -Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. +Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. -Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. +Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure. `RemoteStream` tests pin single consumption, retry reset after opening acceptance, generation-only `restart()`, no retry for terminal errors, and disposal quiescence. @@ -364,6 +368,8 @@ Durable logs repair a missing suffix by sequence number and page; Session contro Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation. +Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code. + Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary. The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller. @@ -372,4 +378,4 @@ Remote waterfalls preserve first claim across multiple Clients, continuation of This decision extends the allowlist and single Cordis-signature design from [Remote event delivery](2026-08-10-remote-event-delivery.md): ordinary notifications use `emit`, while Agent-scoped async waterfalls use the same `ctx.remote.$on` surface with explicit `waterfall` mode. It creates no second invocation map. -This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md). +This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md). diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 8bd00def45..10edeff166 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -62,21 +62,23 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 +Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。 + 首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。 -Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。 +Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送 `{ type: 'ready', clientId, host: { home } }`;该 frame 证明当前 generation 已经能够接收增量,并携带稳定的 Host 路径显示信息。 -`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 +`ConnectionController` 只有在 `$events` ready 后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 -`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。 +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,退避后重新建立 `$events`。 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 -插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 +Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 ### 通用 Remote stream 模型 @@ -134,7 +136,7 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 -它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `llm.models` 公开,因为它不属于特定 Session。 +它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 @@ -320,15 +322,17 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace **给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 +**发送应用层 JSON 心跳帧。** 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。 + **继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 **从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 ## 验证 -Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 -Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 +Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。 `RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。 @@ -364,6 +368,8 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 +每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。 + Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 @@ -372,4 +378,4 @@ Remote waterfall 保留多 Client 首个 claim、全体 `next` 后继续 Host ch 本决定扩展[Remote 事件投递](2026-08-10-remote-event-delivery.zh.md)的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 `emit`,Agent-scoped async waterfall 使用同一 `ctx.remote.$on` 面和显式 `waterfall` mode;不建立第二套 invocation map。 -本决定接管[简单一元 API Proxy 迁移](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 +本决定接管[简单一元 API Proxy 迁移](2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml index 745c6b3fe0..98c731e584 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.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 .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md -2026-08-19-session-projection-state-and-client-views.md: 16489a4571fa53d561e3a84e0d8532146dd4e263 -2026-08-19-session-projection-state-and-client-views.zh.md: 36349610c96a73ddde95fa24b841152decfa7ac2 +2026-08-19-session-projection-state-and-client-views.md: 14da0525b2cc838ff496d5902dd66ae6ab456af4 +2026-08-19-session-projection-state-and-client-views.zh.md: 3b1ed72bf240ebf43924826d42226ff301ad8fca diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md index 16489a4571..14da0525b2 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md @@ -6,7 +6,7 @@ English | [中文](2026-08-19-session-projection-state-and-client-views.zh.md) ## Problem -The projection registry persisted each unit's internal fold state without a runtime schema, while `SessionProjectionMap` described the client value returned by `view`. This left restored state unvalidated and made the same type table appear to describe two values that may differ. Host consumers also needed the current folded state without serializing every registered client view or exposing internal-only state through the client protocol. Fork-sensitive units additionally needed the existing immutable Session header to be validated consistently against each observed log. +The projection registry persisted each unit's internal fold state without a runtime schema, while `SessionProjectionMap` described the client value returned by `view`. This left restored state unvalidated and made the same type table appear to describe two values that may differ. Host consumers also needed the current folded state without serializing every registered client view or exposing internal-only state through the client protocol. ## Decision @@ -14,11 +14,9 @@ The projection registry persisted each unit's internal fold state without a runt A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. -`ProjectionDefinition.init(header)` retains the existing immutable-header contract. Before every live, cache, history, and detached initialization, the registry normalizes `header.seedLength ?? 0` and rejects a boundary beyond the observed log. Each definition interprets only the immutable creation facts it owns, such as Schedule's fork boundary or the initial `agentPreset`, without consulting ambient mutable state or adding a second initialization protocol. - ## Consequences -Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; exact prepared-session reads can discard it and rebuild from the log. Host consumers can replace private log scans with the same incremental fold used by carriers. Fork-sensitive and creation-value units derive their state directly from the same immutable header that accompanies the observed events. +Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers. The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md index 36349610c9..3b1ed72bf2 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md @@ -6,7 +6,7 @@ ## 问题 -投影注册表会持久化各单元的内部折叠状态,却没有运行时 schema;与此同时,`SessionProjectionMap` 描述的是 `view` 返回的客户端值。这使恢复出的状态未经校验,也让同一张类型表看似同时描述两种可能不同的值。host 消费方还需要读取当前折叠状态,但不应为此序列化全部已注册客户端视图,也不应把内部状态暴露到客户端协议。fork-sensitive 单元还需要把既有不可变 Session header 与每次观察到的日志一致校验。 +投影注册表会持久化各单元的内部折叠状态,却没有运行时 schema;与此同时,`SessionProjectionMap` 描述的是 `view` 返回的客户端值。这使恢复出的状态未经校验,也让同一张类型表看似同时描述两种可能不同的值。host 消费方还需要读取当前折叠状态,但不应为此序列化全部已注册客户端视图,也不应把内部状态暴露到客户端协议。 ## 决策 @@ -14,11 +14,9 @@ 如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 -`ProjectionDefinition.init(header)` 保留既有的不可变 header 合同。注册表会在每条 live、cache、history 与 detached 初始化路径上规范化 `header.seedLength ?? 0`,并拒绝超过已观察日志长度的边界。每个 definition 只解释自己拥有的不可变创建事实,例如 Schedule 的 fork 边界或初始 `agentPreset`,无需读取环境可变状态,也不增加第二初始化协议。 - ## 结果 -投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;精确 prepared-session 读取可以丢弃它并从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。fork-sensitive 与创建值单元都直接从配套已观察事件的同一个不可变 header 派生状态。 +投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。 原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.zh.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.zh.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml similarity index 57% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml index 34b44986bf..47b37fad16 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.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 .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md -2026-07-26-code-dispatch-ui-foundation.md: 2b919dfb978ef3df0e65d2e0e410e2bd07c75a46 -2026-07-26-code-dispatch-ui-foundation.zh.md: 33599c5689973d05ca6ec98fbf919cea6fc1cd4d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md +2026-08-23-cross-realm-cdp-inspector.md: e6e1d48da4f7b2dfab2772e24cb49711e3e50f5a +2026-08-23-cross-realm-cdp-inspector.zh.md: 9d67868caf9b4e5e0eb06583facd608b71468b22 diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md new file mode 100644 index 0000000000..e6e1d48da4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md @@ -0,0 +1,100 @@ +# Agent Note: Cross-realm CDP inspector + +Status: implemented + +English | [中文](2026-08-23-cross-realm-cdp-inspector.zh.md) + +## Problem + +Host diagnostics, browser Client observations, and JavaScript debugging originate in different JavaScript realms. A debugger transport implemented on the Host main thread cannot deliver `Debugger.resume` while that thread is paused, and a design that lets each producer emit CDP directly duplicates protocol state and couples application instrumentation to Chrome's presentation protocol. + +## Decision + +`@deepseek-ai/dsh-experimental-inspector` is one private Client/Host Cordis plugin package. Its Host face starts a Node Worker; its Client face connects directly to that Worker. Cordis owns composition, service publication, bootstrap injection, and disposal only. The source protocol, Worker state, CDP server, V8 bridge, and domain adapters do not inspect Cordis runtime data. + +The Worker is the sole CDP endpoint and the sole owner of CDP state. Host and Client producers send validated observations under a versioned internal protocol; Client Runtime, Console, Sources, and semantic queries use separate typed frame families on the same authenticated carrier. A realm registry gives every DevTools connection the same Runtime, Console, Sources, and Debugger capability slots, while explicit unsupported members preserve different Host and Client support levels. + +## Realm ownership + +The Host main thread owns application objects and `globalThis.fetch`. It sends observations over a dedicated `MessagePort` and never constructs CDP messages. + +The Client page owns browser observations, evaluated values, and Client object handles. It exchanges JSON frames directly with the Worker over an authenticated ingest WebSocket, so a paused Host does not stop Client delivery or Runtime execution. + +The Inspector Worker owns HTTP discovery, both WebSocket routes, source generations, retention, realm sessions, CDP sessions, and domain adapters. Each DevTools connection opens one backend session for the Host and every connected Client realm. V8 object ids remain inside the Node Runtime backend. Client object handles remain inside the typed Client protocol. One connection-local object table maps either backend handle to CDP object ids and projects the same RemoteObject, property, exception, Console, and paused-frame types. + +Chrome DevTools consumes one page-type target. Runtime methods route by execution context or object id. Debugger source methods route by script id; Host scripts retain native debugging, while Client scripts expose read-only content and reject active debugging. `Profiler` and `HeapProfiler` remain Host-only. `Network` and the minimal page-target scaffold run inside the Worker. + +## Source protocol + +Both MessagePort and WebSocket carriers use the same JSON value set and discriminated frames. A source identifies one logical producer and one connection generation, declares capabilities and topics, sends an initial replacement, then appends sequence-numbered batches. The Worker rejects malformed, oversized, stale-generation, and undeclared-topic frames before reading domain fields. + +Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Host MessagePort carrier permits one append batch in flight and sends the next after the Worker acknowledges consumption. The Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects. + +Runtime frames use closed command and result unions instead of method strings with untyped parameter records. Every request carries a source id, source generation, DevTools Runtime session id, request id, and command. Every result repeats those identities and the command discriminant. Console lifecycle/events, chunked source reads, and non-CDP semantic queries have separate correlated frame families. RemoteObject values, previews, property descriptors, call arguments, exceptions, Console events, debugger frames, scripts, and errors have dedicated exact decoders. + +## Client Runtime, Console, and Sources + +`Runtime.enable` publishes the Host's real execution context and one negative-id synthetic execution context for each connected Client source that declares the Runtime capability. An omitted context continues to mean Host. Client source replacement destroys the old context and creates a new context with a new generation and unique id. + +The Client Runtime subset covers `Runtime.evaluate`, `Runtime.getProperties`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, `Runtime.releaseObject`, `Runtime.releaseObjectGroup`, and `Runtime.globalLexicalScopeNames`. The Client executes commands in its page realm and retains live objects in a table isolated by DevTools Runtime session. It returns opaque handles and JSON-safe metadata; the Worker validates the result and assigns a connection-local CDP object id. An object argument may be used only by the same Client source generation and DevTools session. Closing the source, disabling Runtime, closing DevTools, releasing an object, or releasing an object group removes the corresponding handles. + +JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. A Worker deadline sends request-scoped cancellation to the Client. Handles allocated for a response remain provisional until the Worker acknowledges that response, so cancellation and late responses cannot leave unreachable objects. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state. + +The Client Console observer preserves the original page call and asynchronously emits one event per enabled DevTools session. Each session serializes arguments into its own `console` object group, so disconnect, Runtime disable, or `Runtime.discardConsoleEntries` can release one connection without invalidating another. Context and Fiber arguments use the same semantic reference and DOM reverse mapping as evaluation results. + +The Client discovers this package's `lib/client.js` URL from the assembled web boot graph. `Debugger.enable` reads metadata through a typed source operation, and `Debugger.getScriptSource` reassembles bounded base64 chunks; the source map remains available at the advertised URL. Client-script breakpoint, step, and call-frame operations remain explicitly unsupported because page JavaScript cannot pause its own realm and continue servicing control messages. Target-wide pause and resume continue to control the Host debugger. + +## Host debugging + +The Worker attaches each DevTools connection to the Host main isolate through its own Node inspector Session. Node Runtime, Console, Sources, and Debugger backends normalize native values and events into the same realm model used by Client backends. The common projector allocates connection-local object ids for evaluation results, Console arguments, paused scopes, and call-frame results. Breakpoint requests are translated back to native backend handles before reaching Node. The default context may receive the display name `Host` while retaining its real id and metadata. + +The Worker event loop, DevTools socket, Client ingest socket, and Node inspector Session remain runnable while Host JavaScript is paused. Host observations naturally stop until resume. + +## Fetch capture + +Fetch capture wraps `globalThis.fetch` and is enabled by default. Every later fetch records its complete URL, headers, request body, response headers, response body, timing, cancellation, and error. No field is redacted by default; using the inspector grants local DevTools access to those secrets. + +The wrapper passes a normalized Request to the original fetch, reads request and response clones on independent capture tasks, and returns the original Response as soon as fetch resolves. Capture failure never changes the caller's fetch result. Finite per-body and journal budgets prevent unbounded retention; exceeding a budget preserves the captured prefix and reports truncation. + +## Alternatives considered + +**Run the CDP server on the Host main thread.** Rejected because a breakpoint freezes the socket responsible for delivering `Debugger.resume`. + +**Relay Client observations through the Host web server.** Rejected because the relay also freezes at a Host breakpoint and makes the Client data path depend on Host responsiveness. + +**Let producers emit CDP messages.** Rejected because producer code would own Chrome-specific request ids, replay, enable state, and ordering instead of domain observations. + +**Share one Node inspector Session across DevTools clients.** Rejected because object ids, object groups, enable state, and debugger operations belong to one protocol session; sharing requires an error-prone virtual-session layer. + +**Send live Client objects or CDP object ids over WebSocket.** Rejected because JSON cannot preserve identity or behavior, and a CDP object id belongs to one DevTools session. Client-local handles plus a Worker-owned per-connection mapping preserve both ownership rules. + +**Use one untyped Runtime RPC method.** Rejected because method strings and arbitrary parameter objects cannot enforce command/result correlation, object-reference ownership, or exhaustive evolution as Runtime, Sources, and Debugger support grows. + +**Split protocol, Host, and Client into separate packages.** Rejected for the experimental phase. One package keeps the capability deployable as one Client/Host plugin while source directories and build entries preserve realm boundaries. + +**Use Undici diagnostics channels as the complete fetch source.** Rejected because they observe transport lifecycle but cannot provide complete request and response bodies without consuming application streams. They may later augment transport-level timing. + +## Verification + +- A real Worker accepts Host MessagePort and Client WebSocket sources and exposes both through one CDP target. +- Malformed, oversized, stale-generation, and sequence-gap frames cannot corrupt another source or the Worker. +- Console evaluates in the Host context and receives Host console events. +- Console lists Host and Client contexts; Client evaluation, properties, function calls, promise awaiting, and release operations preserve RemoteObject identity without sharing objects across realms or DevTools connections. +- Host and Client Console events use the same projector; Client arguments remain isolated by DevTools connection and Cordis arguments resolve to Elements nodes. +- Sources receives Host scripts and the built Client bundle; Client source reads are chunked and active debugging fails explicitly, while a breakpoint can pause the Host, evaluate a call frame, and resume. +- Host paused scopes and call-frame results use the same connection-local RemoteObject table as Runtime evaluation. +- Network replays requests that predate `Network.enable` and streams later requests without loss or duplication. +- Successful, failed, aborted, redirected, textual, binary, streaming, and truncated fetches preserve caller behavior and expose the configured captured data. +- Disposal stops capture, closes admission, disconnects V8 sessions, closes sockets, and waits for Worker exit before completing. + +## Consequences + +The Worker-owned endpoint keeps DevTools control responsive while Host JavaScript is paused and gives Host and Client observations one CDP state owner. That ownership adds the following security, resource, and compatibility costs. + +Full fetch capture intentionally exposes credentials and payloads to any local process that can attach to the CDP endpoint. Loopback binding is mandatory but is not authentication. + +Cloning request and response streams adds CPU, memory, and I/O pressure. Finite limits bound retained bytes but cannot make full capture free. + +A page-type synthetic target depends on a small set of Chrome DevTools compatibility responses outside Node's native inspector domains. Each no-op must be named and covered because silently accepting every unknown method hides protocol drift. + +Client Runtime execution uses page JavaScript evaluation, so page Content Security Policy may reject it and native DevTools command-line or REPL semantics are not promised. Read-only Client Sources do not imply active Client debugging; adding that capability requires an execution agent that remains responsive while the inspected page realm is paused. diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md new file mode 100644 index 0000000000..9d67868caf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md @@ -0,0 +1,100 @@ +# Agent Note: 跨 realm CDP Inspector + +Status: implemented + +[English](2026-08-23-cross-realm-cdp-inspector.md) | 中文 + +## Problem + +Host 诊断、浏览器 Client 观测和 JavaScript 调试来自不同 JavaScript realm。Host 主线程上的 debugger transport 无法在该线程暂停时投递 `Debugger.resume`;若每个 producer 直接生成 CDP,又会重复协议状态,并把应用观测逻辑绑到 Chrome 呈现协议。 + +## Decision + +`@deepseek-ai/dsh-experimental-inspector` 是一个私有 Client/Host 双面 Cordis 插件包。Host 面启动 Node Worker,Client 面直接连接该 Worker。Cordis 只负责组合、服务发布、bootstrap 注入与 dispose;source 协议、Worker 状态、CDP server、V8 bridge 和 domain adapter 不检查 Cordis 运行时数据。 + +Worker 是唯一 CDP endpoint,也是 CDP 状态的唯一 owner。Host 与 Client producer 通过有版本的内部协议发送验证后的观测记录;Client Runtime、Console、Sources 和语义查询在同一条鉴权 carrier 上使用相互独立的类型化帧。realm registry 为每条 DevTools 连接提供相同的 Runtime、Console、Sources 和 Debugger capability slot,并用明确的 unsupported 成员保留 Host 与 Client 的支持差异。 + +## Realm 所有权 + +Host 主线程拥有应用对象和 `globalThis.fetch`。它通过专用 `MessagePort` 发送观测记录,绝不构造 CDP 消息。 + +Client 页面拥有浏览器观测、求值得到的值和 Client object handle。它通过带鉴权的 ingest WebSocket 直接与 Worker 交换 JSON 帧,因此 Host 暂停不会阻断 Client 投递或 Runtime 执行。 + +Inspector Worker 拥有 HTTP discovery、两条 WebSocket route、source generation、保留历史、realm session、CDP session 和 domain adapter。每条 DevTools 连接为 Host 和每个已连接 Client realm 分别建立一套 backend session。V8 object id 只留在 Node Runtime backend 内。Client object handle 只留在类型化 Client 协议内。单个 connection-local object table 把两类 backend handle 映射成 CDP object id,并投影同一种 RemoteObject、property、exception、Console 与 paused-frame 类型。 + +Chrome DevTools 消费一个 page 类型 target。Runtime 方法按 execution context 或 object id 路由;Debugger source 方法按 script id 路由。Host script 保留原生调试,Client script 只暴露只读内容,并拒绝 active debugging。`Profiler` 与 `HeapProfiler` 仍然只属于 Host;`Network` 与最小 page-target scaffold 在 Worker 内执行。 + +## Source 协议 + +MessagePort 与 WebSocket carrier 使用同一组 JSON 值和判别联合帧。source 标识一个逻辑 producer 和一个连接 generation,声明 capability 与 topic,发送初始 replace,再追加带 sequence 的 batch。Worker 在读取 domain 字段前拒绝畸形、超限、旧 generation 和未声明 topic 的帧。 + +投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;Host MessagePort carrier 同时只允许一个 append batch 在途,并在 Worker 确认消费后发送下一批。无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。 + +Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符串加无类型 parameter record。每个 request 携带 source id、source generation、DevTools Runtime session id、request id 和 command;每个 result 重复这些身份与 command 判别符。Console lifecycle/event、分块 source 读取和非 CDP 语义查询使用各自独立的关联帧。RemoteObject value、preview、property descriptor、call argument、exception、Console event、debugger frame、script 与 error 都有独立的精确 decoder。 + +## Client Runtime、Console 与 Sources + +`Runtime.enable` 发布 Host 的真实 execution context,并为每个声明 Runtime 能力的已连接 Client source 发布一个负数 id synthetic execution context。不指定 context 仍然表示 Host。Client source replacement 会销毁旧 context,并以新的 generation 与 unique id 创建新 context。 + +Client Runtime 子集包括 `Runtime.evaluate`、`Runtime.getProperties`、`Runtime.callFunctionOn`、`Runtime.awaitPromise`、`Runtime.releaseObject`、`Runtime.releaseObjectGroup` 和 `Runtime.globalLexicalScopeNames`。Client 在页面 realm 中执行命令,并在按 DevTools Runtime session 隔离的表中保留实时对象。Client 只返回不透明 handle 与 JSON-safe metadata;Worker 验证结果并分配连接私有的 CDP object id。对象参数只能由同一 Client source generation 与 DevTools session 使用。source 断开、Runtime disable、DevTools 关闭、释放对象或释放 object group 都会移除对应 handle。 + +JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。Worker deadline 会向 Client 发送 request-scoped cancellation。response 分配的 handle 在 Worker 确认该 response 前保持 provisional,因此 cancellation 和 late response 不会留下无法访问的对象。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。 + +Client Console observer 保持原始页面调用行为,并为每个已启用的 DevTools session 异步发出一份 event。每个 session 把 argument 序列化到自己的 `console` object group,因此断联、Runtime disable 或 `Runtime.discardConsoleEntries` 可以释放一条连接而不使其他连接失效。Context 与 Fiber argument 使用和求值结果相同的语义引用及 DOM 反向映射。 + +Client 从组装后的 web boot graph 发现本包 `lib/client.js` 的 URL。`Debugger.enable` 通过类型化 source operation 读取 metadata,`Debugger.getScriptSource` 重组有界 base64 chunk;source map 保持在公布的 URL 上可用。Client script breakpoint、step 与 call-frame 操作明确不受支持,因为页面 JavaScript 无法暂停自身 realm 后继续处理控制消息。target-wide pause 与 resume 继续控制 Host debugger。 + +## Host 调试 + +Worker 为每条 DevTools 连接建立独立 Node inspector Session,并连接 Host 主 isolate。Node Runtime、Console、Sources 与 Debugger backend 把原生 value 和 event 归一化成 Client backend 使用的同一种 realm model。公共 projector 为求值结果、Console argument、paused scope 和 call-frame result 分配 connection-local object id。breakpoint request 到达 Node 前会反向转换成原生 backend handle。默认 context 可以改显示名为 `Host`,但保留真实 id 和 metadata。 + +Host JavaScript 暂停时,Worker event loop、DevTools socket、Client ingest socket 与 Node inspector Session 仍可运行。Host 观测自然暂停到 resume。 + +## Fetch 采集 + +fetch 采集包装 `globalThis.fetch`,并默认开启。之后每次 fetch 都记录完整 URL、headers、请求体、响应 headers、响应体、时间、取消与错误。默认不脱敏任何字段;启用 Inspector 即把这些秘密交给本机 DevTools。 + +wrapper 把标准化 Request 交给原 fetch,通过独立采集任务读取 request/response clone,并在 fetch resolve 后立即把原始 Response 交给调用方。采集失败不得改变调用方的 fetch 结果。有限的单体与 journal 预算阻止无界保留;超过预算时保留已采集前缀并报告截断。 + +## Alternatives considered + +**在 Host 主线程运行 CDP server。** 拒绝,因为断点会冻结负责投递 `Debugger.resume` 的 socket。 + +**经 Host web server 中转 Client 观测。** 拒绝,因为 Host 断点同样冻结中转,并使 Client 数据路径依赖 Host 响应。 + +**让 producer 直接生成 CDP 消息。** 拒绝,因为 Chrome 专用 request id、回放、enable 状态和排序会落入 producer,而不是领域观测。 + +**多个 DevTools client 共用一个 Node inspector Session。** 拒绝,因为 object id、object group、enable 状态与 debugger 操作属于单个协议 session;共享需要易错的虚拟 session 层。 + +**通过 WebSocket 发送 Client 实时对象或 CDP object id。** 拒绝,因为 JSON 无法保留对象身份或行为,而 CDP object id 只属于一条 DevTools session。Client-local handle 加 Worker 所有的逐连接映射同时维护这两条所有权规则。 + +**使用一个无类型 Runtime RPC method。** 拒绝,因为 method 字符串和任意 parameter object 无法保证 command/result 关联、对象引用所有权,也无法在 Runtime、Sources 与 Debugger 支持增长时做穷尽演进。 + +**把 protocol、Host 与 Client 拆成多个包。** 实验阶段拒绝。一个包保持能力以一个 Client/Host 插件部署,同时由源码目录与构建入口维护 realm 边界。 + +**用 Undici diagnostics channel 作为完整 fetch 数据源。** 拒绝,因为它能观察 transport lifecycle,却无法在不消费应用 stream 的前提下提供完整 request/response body。后续可以用它补充 transport 级 timing。 + +## Verification + +- 真实 Worker 同时接收 Host MessagePort 与 Client WebSocket source,并通过一个 CDP target 暴露两者。 +- 畸形、超限、旧 generation 与 sequence gap 帧不会破坏其他 source 或 Worker。 +- Console 在 Host context 求值并接收 Host console event。 +- Console 列出 Host 与 Client context;Client 求值、属性、函数调用、Promise await 与释放操作维持 RemoteObject 身份,且不在 realm 或 DevTools 连接之间共享对象。 +- Host 与 Client Console event 使用相同 projector;Client argument 按 DevTools 连接隔离,Cordis argument 可以解析到 Elements node。 +- Sources 接收 Host script 与构建后的 Client bundle;Client source 读取采用分块传输,active debugging 明确失败,而 Host 仍可被断点暂停、求值 call frame 并 resume。 +- Host paused scope 与 call-frame result 使用和 Runtime 求值相同的 connection-local RemoteObject table。 +- Network 回放 `Network.enable` 前的请求,并无遗漏、无重复地推送后续请求。 +- 成功、失败、取消、重定向、文本、二进制、流式与截断 fetch 都保持调用方行为,并暴露配置允许的完整采集数据。 +- dispose 停止采集、关闭入口、断开 V8 session、关闭 socket,并等待 Worker exit 后完成。 + +## Consequences + +Worker 所有的 endpoint 在 Host JavaScript 暂停时仍保持 DevTools 控制可响应,并让 Host 与 Client 观测共享唯一 CDP 状态 owner。这项所有权带来以下安全、资源与兼容性成本。 + +完整 fetch 采集会有意把 credential 和 payload 暴露给任何能连接 CDP endpoint 的本机进程。loopback 监听是强制要求,但不是鉴权。 + +clone request/response stream 会增加 CPU、内存与 I/O 压力。有限预算能约束保留字节,不能让完整采集没有成本。 + +page 类型 synthetic target 依赖 Node 原生 inspector domain 之外的一组 Chrome DevTools 兼容响应。每个 no-op 都必须明确命名并有测试;统一吞掉未知方法会掩盖协议漂移。 + +Client Runtime 执行使用页面 JavaScript 求值,因此页面 Content Security Policy 可能拒绝它,也不承诺原生 DevTools command-line 或 REPL 语义。只读 Client Sources 不代表 active Client debugging;增加该能力需要一个在被检查页面 realm 暂停时仍能响应的执行 agent。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 6756a4d842..88e2561815 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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 .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: c75f561d9529296ff668791c29453e522f309cc3 -2026-08-24-browser-token-authentication.zh.md: d4a5059619fefda9d9060e9879d10c0a2197f8f3 +2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4 +2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index c75f561d95..cdfed1f250 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im ## Verification -Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. +Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for generic RPC, Typert Remote HTTP, exact Fetch routes, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `settings/describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index d4a5059619..29f3a6c0a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` ## 验证 -单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 +单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定通用 RPC、Typert Remote HTTP、精确 Fetch 路由和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `settings/describe`,观测新的进程令牌,并在重启后复用旧 cookie。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml index 8ded320f05..212a394e8a 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.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 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md -2026-08-04-websocket-downlink-carrier.md: 420a0d30f31cca58448adc0afd46a5d6d5e9107f -2026-08-04-websocket-downlink-carrier.zh.md: 5f277a4ed33ffe97b51587fef960746b93eff1c1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md +2026-08-24-cordis-runtime-tree-inspection.md: 2cd63aa41567adc19d52d584874583183aef3cdc +2026-08-24-cordis-runtime-tree-inspection.zh.md: 88cf2b344d4feef10f3632918539d3cb98750fc9 diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md new file mode 100644 index 0000000000..2cd63aa415 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md @@ -0,0 +1,113 @@ +# Agent Note: Cordis runtime tree inspection + +Status: implemented + +English | [中文](2026-08-24-cordis-runtime-tree-inspection.zh.md) + +## Problem + +The Inspector needs to present each Host and Client Cordis runtime as a tree in Chrome DevTools Elements. A Cordis Context or Fiber selected in Elements must also behave as a live Runtime object, while a Cordis object printed in Console must be revealable as the same semantic node. CDP identifiers cannot be the source model: `NodeId`, `BackendNodeId`, and `RemoteObjectId` have different owners and lifetimes, and a future model-facing runtime query must consume the same Cordis data without translating CDP. + +Host and Client run the same Cordis abstractions in different JavaScript realms. Tree discovery and classification must therefore be one browser-safe implementation, while object resolution remains realm-local and only opaque references cross MessagePort or WebSocket boundaries. + +## Decision + +The Inspector uses one serialized Cordis tree model and keeps CDP as one adapter over it. The package separates live-object discovery, immutable snapshots, Worker-owned storage, and consumers: + +The existing [cross-realm Inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) owns the Worker, source carriers, Runtime routing, and security model. This note owns only the Cordis semantic data and its consumers. + +```text +Host Context/Fiber ─┐ + ├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter +Client Context/Fiber┘ └─ future model adapter +``` + +`CordisTreeCollector` and its identity registry are browser-safe modules compiled into both package faces. Host and Client instantiate that same code against their own `ctx.root`; neither side carries a second classification implementation. + +## Cordis tree model + +`CordisTreeSnapshot` is a CDP-independent, lossless-JSON value with a schema version, monotonically increasing revision, object-registry id, truncation flag, and one nested root Context. Context nodes contain an opaque object handle and ordered Context/Fiber children. Fiber nodes contain their Cordis `uid`, an opaque object handle, and exactly one Context child representing `fiber.ctx`. Host and Client publish this same realm-tree type. No generated Context id, plugin metadata, service data, arbitrary property value, or object preview enters the tree. + +The inspection tree starts at the root Context and omits the Cordis root Fiber. For every other plugin, its parent Context contains the Fiber and that Fiber contains its owned Context. A Context created by `extend()`, `isolate()`, or `intercept()` without a new Fiber remains a direct Context child. Nesting expresses parentage without generated node ids and preserves both object identities without introducing the `Fiber.ctx` / `Context.fiber` cycle. + +The collector starts from the root, every live registry Fiber, and every event hook's owning Context. It follows Context prototype links back to the inspected root, unwraps Cordis shadow contexts, deduplicates by object identity, and excludes disposed fibers. `internal/plugin` and `internal/status` events schedule one microtask-coalesced replacement snapshot. Node-count and encoded-byte limits remove complete trailing branches, so every retained node still has its parent and every retained Fiber still has its owned Context. + +## Identity and lifetime + +The identities are intentionally distinct: + +- Fiber `uid` comes from Cordis. Context currently has no Cordis-owned id and the Inspector does not expose a generated substitute. +- `InspectorObjectReference` is an opaque realm-local handle resolving a tree node to its live Context or Fiber. Snapshots carry the handle for routing, never as a semantic id or DOM attribute. +- `BackendNodeId` is assigned by the Worker to one retained `(source id, source generation, object reference)` and is shared by DevTools connections while that generation's snapshot is retained. +- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes. +- `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group. + +`sourceId` identifies one browser-tab Client runtime and is retained in that tab's `sessionStorage`, so automatic transport reconnects and page refreshes reuse it. Before opening the transport, a Client with Web Locks claims that id for its page lifetime; a simultaneously live tab copied from the same storage state cannot claim it and persists a fresh id instead. Browsers without Web Locks retain storage-backed refresh identity but cannot arbitrate copied live tabs. `generation` identifies one WebSocket admission and always rotates. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated. + +Standard CDP does not place a `RemoteObjectId` field on `DOM.Node`. `DOM.Node` carries `nodeId` and `backendNodeId`; `DOM.resolveNode` returns the corresponding `Runtime.RemoteObject`, and `DOM.requestNode` performs the reverse mapping. The implementation keeps these three CDP identities correlated without adding non-standard DOM fields. + +## Realm object bridge + +Each collector registers a realm-local object table under a private global symbol. The table maps opaque handles to live objects and can identify a currently retained object by identity. Replacing a snapshot removes handles absent from the new tree; disposing the observer unregisters the table. + +For Host nodes, the Worker uses that DevTools connection's private `node:inspector.Session` to evaluate a lookup in the Host table, producing a native V8 `RemoteObjectId`. For Client nodes, the Worker routes the same lookup through the existing typed Client Runtime channel and maps the returned Client handle to a connection-local CDP object id. No live object or engine object id crosses a source transport. + +Client Runtime values carry an optional validated `InspectorObjectReference`, while Host Runtime values are probed through their native V8 object id. The common CDP adapter changes recognized evaluation results, properties, exceptions, Console arguments, and paused-frame objects to `subtype: "node"`, records the object-id-to-backend-node relation, and supplies the Cordis element description. This gives both directions: Elements can expose a live object, and a Context or Fiber returned or printed in Console can be revealed in Elements. + +## Worker repository and updates + +Sources publish the Cordis tree as retained state rather than an event history. Host MessagePort and Client WebSocket publishers keep the latest state record and include it in `source/replace` after admission, reconnection, or a resnapshot request. Live replacements still use the ordinary sequenced append path. The Worker validates every snapshot for exact fields, bounded node count and depth, unique object handles and Fiber uids, a Context root, and exactly one Context child per Fiber before atomically replacing the prior tree. + +`CordisTreeStore` owns validated realm snapshots and source lifecycle only. Its internal reader retains live object routes for Runtime and DOM, while its public reader projects a detached `{ host, clients }` tree without transport or CDP ids. Host and Client `ctx.inspector.cordis.getTree()` calls use the same correlated query protocol and Worker reader without creating a CDP session. `CordisDomBackend` adds Worker-global backend ids, while each `CordisDomSession` owns frontend node ids, searches, enabled state, and RemoteObject correlations. A model adapter can consume the public reader without depending on DOM serialization or debugger activation. + +Closing a source changes its stored tree from connected to disconnected instead of deleting the last snapshot. Object lookup excludes disconnected trees, so the snapshot remains inspectable as data without retaining or reviving a live Context, Fiber, or Runtime object. A replacement from the same source id and a new transport generation atomically restores the connected state. The configurable disconnected-tree limit evicts the oldest retained snapshots. + +Accepted source snapshots rebuild the connection-neutral document and are diffed by stable backend node identity. A revision-only replacement emits no DOM event. Child insertion and removal use `DOM.childNodeInserted` and `DOM.childNodeRemoved`; attribute changes use their corresponding DOM events; sibling reorder falls back to `DOM.setChildNodes` for that parent only. Reusing one backend identity for a different node kind is the sole `DOM.documentUpdated` fallback. A disconnect invalidates object routes without changing the retained DOM tree, preserving expansion and selection; retention eviction removes only the evicted `` node. + +## CDP projection + +The synthetic document has a `` container and a `` container. `` contains the Host root Context. `` contains one `` per Client source, and each `` contains that realm's root Context. These structural elements have no Runtime object or attributes. Context elements have no attributes. Fiber elements expose only `uid`, copied without reinterpretation from Cordis. Connected Context and Fiber elements resolve to live RemoteObjects; disconnected snapshots retain their DOM nodes but object resolution fails. + +Standard CDP has no backend-controlled frozen, locked, or dimmed state for a node in the ordinary Elements tree. Chromium's detached-node presentation is frontend-local to the Memory panel's `DOM.getDetachedDomNodes` flow. No connection-state attribute or non-standard `DOM.Node` field is added until its presentation is decided. + +The read-only adapter implements document retrieval, child requests, node description, attributes, outer HTML, search, backend-id pushes, node resolution, and reverse object lookup. Mutating DOM methods fail explicitly. Layout, CSS, accessibility, and browser DOM geometry are outside this semantic tree and return empty or unsupported responses only where Chrome DevTools requires a compatibility response. + +## Alternatives considered + +**Build CDP DOM nodes directly in each realm.** Rejected because Host and Client would duplicate classification, frontend ids would leak into source protocols, and a model consumer would need to reverse a presentation protocol back into Cordis concepts. + +**Send live objects or V8 object ids to the Worker.** Rejected because structured clone and JSON do not preserve identity or behavior, and engine object ids belong to one inspector session. + +**Generate an Inspector Context id.** Rejected because Cordis Context has no intrinsic id and a presentation adapter must not make an implementation key look like framework identity. Nested children express parentage; opaque object handles remain routing data. + +**Use one id for Fiber uid, backend nodes, and frontend nodes.** Rejected because source reconnection, multiple DevTools connections, document refresh, and Runtime object release have independent lifetimes. + +**Expose only Contexts and treat each Fiber-owned Context as the Fiber.** Rejected because it loses one of the two live objects, makes Console identity ambiguous, and prevents later Fiber-specific properties from having a stable owner. + +**Put the model-facing API on the CDP adapter.** Rejected because model access would inherit Chrome-specific node serialization, per-connection ids, and enable state. The Worker repository is the shared source; CDP and model access are sibling adapters. + +**Remove a realm tree when its source disconnects.** Rejected because transport loss would discard the last useful topology and collapse the user's Elements inspection state. Keeping old object handles usable was also rejected: a new connection generation cannot prove that any prior live object still exists. + +## Verification + +- The same collector implementation produces Host and Client snapshots from equivalent Cordis runtimes. +- Elements shows `` and `/` containers with each realm's root Context directly beneath its container. +- Context elements have no attributes; Fiber elements expose only their Cordis `uid`; the root Fiber is absent. +- Every connected Context and Fiber has a connection-local frontend node id, a Worker backend node id, and a resolvable connection-local Runtime object id without exposing them as attributes. +- `DOM.resolveNode` and `DOM.requestNode` round-trip Context and Fiber identities without sharing object ids across DevTools connections or source generations. +- A Context or Fiber returned by Runtime evaluation is node-branded and can be revealed in Elements. +- Disconnect destroys the Client execution context and its RemoteObjects while retaining the last Elements tree unchanged; a new transport generation replaces it after a complete snapshot arrives. +- Reconnect and resnapshot replay the latest tree state; unchanged snapshots emit no DOM mutation, while structural changes update only their affected parent or node. Malformed or oversized replacements do not replace the last valid snapshot. +- The stored snapshot and query API contain no CDP types and can support a future model-facing adapter unchanged. + +## Consequences + +Cordis exposes no complete global Context registry. The collector can recover contexts reachable from live fibers and event hooks; a context that is created, never used, and retained only by application code is intentionally absent. + +Object recognition adds a Runtime round trip for each Host object that requires semantic identification. An annotation failure leaves an ordinary RemoteObject rather than breaking Runtime or Debugger delivery. Client Console observation preserves the original method result and schedules serialization afterward; each enabled DevTools session receives independently retained handles, so recognition never blocks the page call or shares objects between connections. + +Sources continue to publish complete snapshots, keeping one shared Host/Client collector and allowing recovery after dropped observations. The Worker pays the snapshot comparison cost, then emits incremental CDP DOM mutations so unchanged revisions do not reset the Elements document. Node and byte limits preserve a valid prefix and report truncation; a later source delta protocol can replace the transport without changing the snapshot model or CDP projection. + +The object table intentionally keeps every object in the current visible tree strongly reachable until the next replacement or observer disposal. This is bounded by the retained snapshot and must not become a general-purpose object registry. + +The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory, and eviction removes the corresponding retained Client subtree. diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md new file mode 100644 index 0000000000..88cf2b344d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md @@ -0,0 +1,113 @@ +# Agent Note: Cordis 运行时树检查 + +Status: implemented + +[English](2026-08-24-cordis-runtime-tree-inspection.md) | 中文 + +## Problem + +Inspector 需要在 Chrome DevTools Elements 中把每个 Host 和 Client Cordis 运行时呈现为一棵树。在 Elements 中选中的 Cordis Context 或 Fiber 也必须表现为实时 Runtime 对象,而 Console 中打印出的 Cordis 对象必须能定位回同一个语义节点。CDP id 不能成为源模型:`NodeId`、`BackendNodeId` 与 `RemoteObjectId` 的 owner 和生命周期不同,并且未来面向模型的运行时查询必须使用同一份 Cordis 数据,而不是再反向解析 CDP。 + +Host 与 Client 在不同 JavaScript realm 中运行相同的 Cordis 抽象。因此,树发现与分类必须只有一份浏览器安全实现;对象解析仍留在各自 realm 内,跨 MessagePort 或 WebSocket 只传递不透明引用。 + +## Decision + +Inspector 使用一套序列化 Cordis 树模型,并把 CDP 作为它的一个适配器。包内分离实时对象发现、不可变快照、Worker 存储与消费方: + +现有的[跨 realm Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、source carrier、Runtime 路由与安全模型;本 Note 只负责 Cordis 语义数据及其消费方。 + +```text +Host Context/Fiber ─┐ + ├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter +Client Context/Fiber┘ └─ future model adapter +``` + +`CordisTreeCollector` 及其身份注册表是浏览器安全模块,同时编入包的两个运行面。Host 与 Client 针对各自的 `ctx.root` 实例化同一份代码;任何一侧都不维护第二套分类实现。 + +## Cordis tree model + +`CordisTreeSnapshot` 是与 CDP 无关的无损 JSON 值,包含 schema 版本、单调递增 revision、对象注册表 id、截断标志和一棵以 Context 为根的嵌套树。Context 节点包含不透明 object handle 与有序的 Context/Fiber children。Fiber 节点包含 Cordis `uid`、不透明 object handle,以及唯一一个表示 `fiber.ctx` 的 Context child。Host 与 Client 发布同一种 realm-tree 类型。生成的 Context id、插件 metadata、服务数据、任意属性值与对象 preview 都不进入树。 + +inspection tree 从 root Context 开始,不包含 Cordis root Fiber。对其他每个插件,其 parent Context 包含 Fiber,该 Fiber 再包含它拥有的 Context。通过 `extend()`、`isolate()` 或 `intercept()` 创建且未创建新 Fiber 的 Context 仍是直接 Context 子节点。嵌套结构无需生成 node id 即可表达 parent,并保留两类对象身份,同时避免把 `Fiber.ctx` / `Context.fiber` 环写入序列化树。 + +collector 从 root、注册表中的每个 live Fiber,以及每个 event hook 的 owner Context 开始。它沿 Context prototype 链回溯到被检查的 root,解开 Cordis shadow Context,按对象身份去重,并排除已 dispose 的 Fiber。`internal/plugin` 与 `internal/status` 事件调度一次 microtask 合并后的 replacement snapshot。节点数和编码字节数限制会移除完整的尾部 branch,因此每个保留节点仍有 parent,每个保留 Fiber 仍有其 owned Context。 + +## Identity and lifetime + +各类身份刻意保持独立: + +- Fiber `uid` 来自 Cordis。Context 当前没有 Cordis 自有 id,Inspector 不会暴露一个生成值来替代。 +- `InspectorObjectReference` 是 realm 本地的不透明 handle,用于把树节点解析成实时 Context 或 Fiber。snapshot 携带该 handle 只为完成路由,不把它当成语义 id 或 DOM attribute。 +- `BackendNodeId` 由 Worker 为一条保留的 `(source id, source generation, object reference)` 分配,并在该 generation 的 snapshot 被保留期间由所有 DevTools 连接共享。 +- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。 +- `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。 + +`sourceId` 标识一个浏览器 tab 的 Client runtime,并保存在该 tab 的 `sessionStorage` 中,因此自动重连 transport 与页面刷新都会复用它。Client 在打开 transport 前会在 Web Locks 可用时独占该 id,直至页面结束;从同一存储状态复制出的另一个同时存活 tab 无法取得该锁,因而会持久化一个新 id。缺少 Web Locks 的浏览器仍保留基于存储的刷新身份,但无法仲裁复制出的 live tab。`generation` 标识一次 WebSocket 接纳且每次都会轮换。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。 + +标准 CDP 不会在 `DOM.Node` 上放置 `RemoteObjectId` 字段。`DOM.Node` 携带 `nodeId` 与 `backendNodeId`;`DOM.resolveNode` 返回对应的 `Runtime.RemoteObject`,`DOM.requestNode` 执行反向映射。实现会关联这三类 CDP 身份,而不添加非标准 DOM 字段。 + +## Realm object bridge + +每个 collector 都在私有 global symbol 下注册一个 realm 本地对象表。该表把不透明 handle 映射到实时对象,并能按身份识别当前保留的对象。替换快照时会移除新树中不存在的 handle;dispose observer 时注销该表。 + +对 Host 节点,Worker 使用该 DevTools 连接私有的 `node:inspector.Session` 在 Host 对象表中执行查询,从而生成原生 V8 `RemoteObjectId`。对 Client 节点,Worker 通过已有的类型化 Client Runtime channel 路由同一查询,再把返回的 Client handle 映射为连接本地 CDP object id。实时对象和引擎 object id 都不会穿过 source transport。 + +Client Runtime value 携带一个可选、已验证的 `InspectorObjectReference`,Host Runtime value 则通过原生 V8 object id 探测。公共 CDP adapter 把已识别的 evaluation result、property、exception、Console argument 和 paused-frame object 改成 `subtype: "node"`,记录 object-id 到 backend-node 的关系,并提供 Cordis element description。这样两个方向都成立:Elements 可以暴露实时对象,Console 中返回或打印的 Context 与 Fiber 也能定位到 Elements。 + +## Worker repository and updates + +source 把 Cordis 树作为保留状态发布,而不是事件历史。Host MessagePort 与 Client WebSocket publisher 保留最新状态记录,并在接纳、重连或收到 resnapshot 请求后把它放入 `source/replace`。实时 replacement 仍走普通的有序 append 路径。Worker 在原子替换旧树前验证 snapshot 的精确字段、节点数与深度限制、object handle 与 Fiber uid 唯一性、Context root,以及每个 Fiber 恰好拥有一个 Context child。 + +`CordisTreeStore` 只拥有已验证 realm snapshot 和 source 生命周期。内部 reader 为 Runtime 与 DOM 保留 live object route;公共 reader 则投影一棵不含 transport 或 CDP id 的 detached `{ host, clients }` tree。Host 与 Client 的 `ctx.inspector.cordis.getTree()` 通过同一套关联查询协议读取同一个 Worker reader,不创建 CDP session。`CordisDomBackend` 增加 Worker 全局 backend id,每个 `CordisDomSession` 则拥有 frontend node id、搜索、enable 状态和 RemoteObject 关联。模型 adapter 可以消费公共 reader,而不依赖 DOM 序列化或 debugger activation。 + +source 关闭时,存储的树从 connected 变为 disconnected,而不是删除最后一份 snapshot。对象查询会排除 disconnected 树,因此 snapshot 仍可作为数据检查,但不会保留或复活实时 Context、Fiber 或 Runtime object。同一 source id 的新 transport generation 提交 replacement 后,会原子恢复 connected 状态。可配置的 disconnected tree 数量上限会淘汰最早保留的 snapshot。 + +每个被接受的 source snapshot 都会重建 connection-neutral document,并按稳定的 backend node identity 比较差异。只改变 revision 的 replacement 不发送 DOM event;子节点增删使用 `DOM.childNodeInserted` 与 `DOM.childNodeRemoved`,attribute 变化使用对应 DOM event,兄弟节点重排只对该 parent 使用 `DOM.setChildNodes`。只有同一 backend identity 被复用为不同 node kind 时才回退到 `DOM.documentUpdated`。断联只会使 object route 失效,不改变保留的 DOM tree,因此保留展开与选择;达到保留上限时只移除被淘汰的 `` 节点。 + +## CDP projection + +synthetic document 包含一个 `` container 和一个 `` container。`` 包含 Host root Context;`` 为每个 Client source 包含一个 ``,每个 `` 再包含该 realm 的 root Context。这些结构 element 没有 Runtime object 或 attribute。Context element 没有 attribute。Fiber element 只暴露从 Cordis 原样复制的 `uid`。connected Context 与 Fiber 可以解析为 live RemoteObject;disconnected snapshot 保留 DOM node,但对象解析失败。 + +标准 CDP 没有可由 backend 控制、用于普通 Elements 树节点的 frozen、locked 或 dimmed 状态。Chromium 的 detached-node 展示只存在于 Memory 面板的 `DOM.getDetachedDomNodes` 流程,并由 frontend 本地设置。在展示方式明确前,不增加 connection-state attribute 或非标准 `DOM.Node` 字段。 + +只读适配器实现 document 获取、子节点请求、节点描述、属性、outer HTML、搜索、backend-id push、节点解析和对象反向查询。修改型 DOM 方法明确失败。layout、CSS、accessibility 与浏览器 DOM geometry 不属于这棵语义树;仅在 Chrome DevTools 需要兼容响应时返回空结果或 unsupported。 + +## Alternatives considered + +**在每个 realm 直接构建 CDP DOM node。** 拒绝,因为 Host 与 Client 会重复分类,frontend id 会泄漏进 source 协议,模型消费方还必须把展示协议反向解析成 Cordis 概念。 + +**把实时对象或 V8 object id 发送给 Worker。** 拒绝,因为 structured clone 与 JSON 无法保留身份或行为,而且引擎 object id 只属于一个 inspector session。 + +**由 Inspector 生成 Context id。** 拒绝,因为 Cordis Context 没有自身 id,展示适配器不能把实现 key 伪装成框架身份。嵌套 children 表达 parent,不透明 object handle 只作为路由数据。 + +**Fiber uid、backend node 与 frontend node 共用一个 id。** 拒绝,因为 source 重连、多条 DevTools 连接、document refresh 与 Runtime object release 的生命周期彼此独立。 + +**只暴露 Context,并把每个 Fiber 拥有的 Context 当成 Fiber。** 拒绝,因为这会丢失两类实时对象中的一类,使 Console 身份产生歧义,并让后续 Fiber 专属属性失去稳定 owner。 + +**把模型访问 API 放在 CDP 适配器上。** 拒绝,因为模型访问会继承 Chrome 专用 node 序列化、逐连接 id 和 enable 状态。Worker repository 是共享数据源,CDP 与模型访问是并列适配器。 + +**source 断联时移除 realm 树。** 拒绝,因为传输中断会丢失最后一份有用拓扑,并折叠用户在 Elements 中的检查状态。继续使用旧 object handle 同样不可接受:新的连接 generation 无法证明任何先前实时对象仍然存在。 + +## Verification + +- 同一个 collector 实现能从等价 Cordis 运行时生成 Host 和 Client 快照。 +- Elements 显示 `` 与 `/` container,每个 realm 的 root Context 直接位于其 container 下。 +- Context element 不含 attribute;Fiber element 只暴露 Cordis `uid`;root Fiber 不出现。 +- 每个 connected Context 与 Fiber 都有一个连接本地 frontend node id、一个 Worker backend node id 和一个可解析的连接本地 Runtime object id,且它们都不作为 attribute 暴露。 +- `DOM.resolveNode` 与 `DOM.requestNode` 能往返映射 Context/Fiber 身份,且不会跨 DevTools 连接或 source generation 共享 object id。 +- Runtime evaluation 返回的 Context 或 Fiber 会被标记为 node,并能在 Elements 中定位。 +- 断联会销毁 Client execution context 与 RemoteObject,同时原样保留最后一棵 Elements 树;新的 transport generation 在完整 snapshot 到达后替换它。 +- 重连和 resnapshot 会重放最新树状态;无变化的 snapshot 不发送 DOM mutation,结构变化只更新受影响的 parent 或 node。畸形或超限 replacement 不会替换最后一个有效快照。 +- 存储的 snapshot 与查询 API 不包含 CDP 类型,可以不加修改地支持未来的模型适配器。 + +## Consequences + +Cordis 不提供完整的全局 Context registry。collector 能恢复从 live fiber 与 event hook 可达的 Context;一个已创建、从未使用且只由应用代码保留的 Context 会有意缺席。 + +需要语义识别的每个 Host object 都会增加一次 Runtime round trip。annotation 失败时保留普通 RemoteObject,不破坏 Runtime 或 Debugger 投递。Client Console observation 保留原始 method result,并在之后调度序列化;每个已启用 DevTools session 独立保留 handle,因此识别既不阻塞页面调用,也不在连接间共享对象。 + +source 仍发布完整 snapshot,从而复用同一套 Host/Client collector,并能在 observation 丢失后恢复。Worker 承担 snapshot 比较成本,再发送增量 CDP DOM mutation,使无变化的 revision 不会重置 Elements document。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换 source delta 协议,而不修改 snapshot model 或 CDP projection。 + +对象表会有意强引用当前可见树中的每个对象,直到下一次 replacement 或 observer dispose。该集合受保留快照限制,不能扩展成通用对象注册表。 + +Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰时会移除对应的已保留 Client subtree。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml similarity index 57% rename from .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml index 9db96229ec..b7b59dc1c4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.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 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md -2026-08-07-code-mode-executor-collapse.md: b19d0842d8b58b170a0f6839dcb32b9390211ca0 -2026-08-07-code-mode-executor-collapse.zh.md: 38bb319c8ce8fd01ab42ad29a6b3748c5d0f7925 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md +2026-08-25-rename-code-mode-to-ptc.md: 9f53b9b5d8c3581d5c2dfe0174ad1c279cf47ba3 +2026-08-25-rename-code-mode-to-ptc.zh.md: 56a9e5ec3ca660fd36d21f9c4dbcb1d5cbd5fbf9 diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md new file mode 100644 index 0000000000..9f53b9b5d8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md @@ -0,0 +1,37 @@ +# Agent Note: Rename code-mode to ptc — the transport is named PTC, user-facing copy says PTC mode + +Status: implemented + +English | [中文](2026-08-25-rename-code-mode-to-ptc.zh.md) + +## Problem + +The tool-registry presentation mode that exposes tools through a generated SDK and the `run_code` transport shipped under the name Code Mode, while the client preset that selects it already shipped as "PTC mode" (locale `presetPtcName: 'PTC mode'`, zh `PTC 模式`). One feature had two names: config values, plugin and event names, files, and documentation said `code`/`code-mode`, and the user-facing name said "PTC mode". A pre-release rename must update every reference together — no compatibility aliases. + +## Decision + +The feature is renamed to PTC (programmatic tool calls). Code identifiers use `ptc` — the transport is not a sibling of plan-mode, so the identifier does not carry `-mode`. User-facing prose keeps "PTC mode" (EN) / "PTC 模式" (zh), matching the shipped preset name. + +Renamed in this PR: + +- config value `tools.mode: 'code'` → `'ptc'` (`ToolPresentationMode` and the zod unions in `dsh-tools` and `dsh-agent-tool-presentation`) +- preset directory `presets/code/` → `presets/ptc/` (preset id `ptc`) +- source and test files `code-mode.ts` → `ptc.ts` and friends; root demo `demo:code-mode` → `demo:ptc` (`scripts/demo-ptc.mjs`) +- the dispatch waterfall `tools/code-dispatch-log` → `tools/ptc-dispatch-log` and types `CodeDispatch*` → `PtcDispatch*` +- prompt rule `tools:code-only` → `tools:ptc-only` +- prose "Code Mode" → "PTC mode" / "PTC 模式" in docs, READMEs, and the eight implemented Agent Notes whose topic names the feature (those files were renamed in place) + +Deferred to the stacked persistence PR: the session-persistent vocabulary — the durable event types `tool/code-dispatch` / `tool/code-dispatch-start`, the logged plugin name `tools-code-mode`, and the sub-call id segment `:code:`. That PR is blocked until the `SESSION_FORMAT_VERSION` v0→v1 migration lands with it. + +Kept unchanged: `run_code` and its `code` parameter (they name the program payload, not the mode), `CodeSdkLanguage`, `CodeRunFailedError`, the `dsh-code-runtime*` package family, the third-party `codex-code-mode-host` binary name, and every frozen archived note. + +## Alternatives considered + +- **`ptc-mode` identifiers** — rejected: PTC is a tool-presentation transport, not a mode in the plan-mode sense, and the identifier should not claim that kinship. +- **Surface-only rename** — rejected: the pre-release stance updates every reference together. +- **Renaming `run_code` too** — rejected: the tool name describes running a program, not the mode, and is model-facing API surface. +- **Renaming the durable event vocabulary in this PR** — rejected: renaming `tool/code-dispatch*` without a format bump would make pre-rename session logs unreadable; that rename belongs to the stacked persistence PR that lands together with the v0→v1 migration. + +## Consequences + +Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are the [session-event-vocabulary note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md). diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md new file mode 100644 index 0000000000..56a9e5ec3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 将 code-mode 重命名为 ptc——传输层命名为 PTC,用户文案使用 PTC mode + +Status: implemented + +[English](2026-08-25-rename-code-mode-to-ptc.md) | 中文 + +## 问题 + +通过生成的 SDK 与 `run_code` 传输层向模型呈现工具的注册表模式,发布时用的名字是 Code Mode;而选择该模式的客户端预设早已以「PTC mode」发布(locale `presetPtcName: 'PTC mode'`,中文 `PTC 模式`)。同一功能有两套名字:配置值、插件与事件名、文件与文档写的是 `code`/`code-mode`,用户可见的名字却是「PTC mode」。预发布阶段的更名必须一次性更新所有引用——不加任何兼容别名。 + +## 决策 + +该功能更名为 PTC(programmatic tool calls,程序化工具调用)。代码标识符使用 `ptc`——该传输层并非 plan-mode 的同类模式,因此标识符不带 `-mode`。用户可见文案沿用「PTC mode」(英文)/「PTC 模式」(中文),与已发布的预设名一致。 + +本 PR 完成的重命名包括: + +- 配置值 `tools.mode: 'code'` → `'ptc'`(`ToolPresentationMode` 以及 `dsh-tools`、`dsh-agent-tool-presentation` 中的 zod union) +- 预设目录 `presets/code/` → `presets/ptc/`(预设 id 为 `ptc`) +- 源文件与测试文件 `code-mode.ts` → `ptc.ts` 等;根 demo `demo:code-mode` → `demo:ptc`(`scripts/demo-ptc.mjs`) +- 分发 waterfall `tools/code-dispatch-log` → `tools/ptc-dispatch-log`,类型 `CodeDispatch*` → `PtcDispatch*` +- 提示词规则 `tools:code-only` → `tools:ptc-only` +- 文档、README 与八个以该功能命名的 implemented Agent Note 中的文案 "Code Mode" → "PTC mode"/"PTC 模式"(这些 Note 文件一并就地改名) + +延后到堆叠的持久化 PR:会话持久词汇——持久事件类型 `tool/code-dispatch`/`tool/code-dispatch-start`、日志中的插件名 `tools-code-mode`、子调用 id 段 `:code:`。该 PR 被阻塞,直到 `SESSION_FORMAT_VERSION` v0→v1 迁移与其一同落地。 + +保持不变:`run_code` 及其 `code` 参数(它们描述程序载荷,而非模式)、`CodeSdkLanguage`、`CodeRunFailedError`、`dsh-code-runtime*` 包族、第三方二进制名 `codex-code-mode-host`,以及所有冻结的 archived Note。 + +## 备选方案 + +- **使用 `ptc-mode` 标识符**——否决:PTC 是工具呈现传输层,不是 plan-mode 意义上的模式,标识符不应宣示这种亲缘关系。 +- **仅重命名表面**——否决:预发布立场要求一次性更新所有引用。 +- **连 `run_code` 一起改名**——否决:该工具名描述的是运行程序,不是模式,而且是对模型可见的 API 表面。 +- **在本 PR 中一并重命名持久事件词汇**——否决:在没有格式版本提升的情况下重命名 `tool/code-dispatch*` 会让更名前的会话日志无法读取;该重命名属于与 v0→v1 迁移一同落地的堆叠持久化 PR。 + +## 后果 + +配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [session event 词汇 Note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index d65f928f5b..d8e1977615 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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 .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 99dd23343b94aed192694d360d63096c89e17c73 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: a43a067474b32a1d626f753b2a82fa4445299209 +2026-08-25-session-observations-and-projection-owned-client-state.md: 492640385215b059761b17a057328cc5c6d24bff +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 0b892a9cae2c999b4472dd46f19068e2b4139e60 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 99dd23343b..4926403852 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -18,7 +18,7 @@ Exact Session reads use a retained `SessionObservation`, and replayable Session- ### Data flow -The two ownership rules meet at the observation's projection snapshot. Lightweight listing may stop at cached hints; every exact opening reaches the same observation path and gives the Client a complete baseline at that observation's cursor. +The two ownership rules meet at the observation's projection snapshot. Lightweight listing may stop at cached hints; every exact opening reaches the same observation path and gives the Client a complete replacement baseline. ```mermaid flowchart LR @@ -94,7 +94,7 @@ A Client-visible fact belongs to `SessionProjectionMap` when its value is determ The three projection delivery states have different meanings: -- A Session-list hint is optional, partial, and unvalidated against the current log extent. It may be stale or claim a cut removed by crash repair. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. +- A Session-list hint is optional, partial, and possibly stale. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. - A follow opening baseline is the complete set of client-visible projection capabilities registered at its cursor. A missing key there means the capability is absent for that Host composition. - An explicit `null` is a domain-computed no-value result. It is distinct from a missing list hint and survives JSON transport. @@ -108,13 +108,13 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client projection store records source provenance and arrival revision beside each `{ value, seq }` row. The latest arriving list hint replaces a tentative row even when crash repair lowered its watermark. A complete authoritative cut blocks later hints. The first authoritative frame replaces a tentative row regardless of sequence, while later frames require a strictly higher sequence. Follow baselines replace the complete store; control baselines exactly replace included Sessions and remove omitted keys. +The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. -Each Session retains one opaque token for initial open, explicit resync, or carrier reconnection and cancels it if that opening fails or is superseded. When a follow baseline completes the token, the store discards pre-token state and retains only authoritative frames that arrived after the token and are newer than the opening cut. A control baseline received after the token at an equal or newer cut remains authoritative. Session does not capture, buffer, or replay projection operations. +The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. -The list view and opened Session read the same per-Session store. Hints can populate title, preset, and other list presentation before the first complete authoritative cut. When a later control generation omits that Session, `SessionManager` invalidates the prior authoritative rows and reinstalls the latest retained list block as tentative. A list pull folds later add and remove mutations over its pull-time hints so a delayed response cannot reverse arrival order. The store never folds Session events: it owns hint/frame provenance, frame ordering, exact baseline replacement, and opening reconciliation; `Session` owns only token lifetime, and `SessionManager` routes list hints, control frames, and control baselines to that resident store. +The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row. -Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. +Data that is not derived from one Session remains outside projections. `session/modelCatalog` owns the Host-generation model catalog, and `agentPresets/list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. Client-local interaction state also remains local: loading and error status, an open menu, an in-flight selection, and a staged choice for a not-yet-created Session are not replayable Session facts. Once a choice applies to a Session, its durable event and projection become authoritative. @@ -146,8 +146,8 @@ Cancellation stops queued or in-flight cold resolution at documented checkpoints | Exact live-preferred read cut | SessionQuery observation | Individual endpoint helpers | | Fold state and client-value computation | Projection registry and domain unit | SessionQuery and Client | | Partial list acceleration | Projection cache and list policy | Follow protocol | -| Opening and reconnect token lifetime | Client Session | Session page and domain UI components | -| Hint, frame, and complete-baseline precedence | Client projection store | Client Session and domain UI components | +| Opening and reconnect replacement | Session follow and journal stream | Session page | +| Per-key value ordering | Client projection store | Domain UI components | | Provider or preset catalog lifecycle | Its catalog directory | Session projection | | Rendering and transient interaction state | Domain UI package | Host projection units | @@ -174,7 +174,7 @@ These rules apply to new Session-derived Client state even when a direct event s Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery. -Client tests pin arrival-ordered tentative hints, first-authoritative-frame takeover, exact opening and equal-cut control replacement, cold-Session omission across both list/control arrival orders, in-flight list-mutation replay, post-token frame retention, stale or canceled opening tokens, manager/Session shared-store routing, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing. +Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index a43a067474..0b892a9cae 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -18,7 +18,7 @@ Session 精确读取使用可保留的 `SessionObservation`,向 Client 暴露 ### 数据动线 -两条 ownership 规则在 observation 的 projection snapshot 处汇合。轻量 list 可以止于 cache hints;每次精确 opening 都进入同一 observation 路径,并向 Client 提供该 observation cursor 上的完整 baseline。 +两条 ownership 规则在 observation 的 projection snapshot 处汇合。轻量 list 可以止于 cache hints;每次精确 opening 都进入同一 observation 路径,并向 Client 提供完整 replacement baseline。 ```mermaid flowchart LR @@ -94,7 +94,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` Projection 的三种交付状态含义不同: -- Session-list hint 是可选、部分且未经当前日志范围校验的数据。它可能陈旧,也可能声称一个已被崩溃修复移除的 cut。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 +- Session-list hint 是可选、部分且可能陈旧的数据。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 - Follow opening baseline 是其 cursor 上所有已注册 Client 可见 projection capability 的完整集合。此处缺少 key 表示当前 Host composition 不具备该 capability。 - 显式 `null` 是领域计算出的无值结果。它不同于 list hint 缺失,并且能够完整通过 JSON transport。 @@ -108,13 +108,13 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client projection store 在每条 `{ value, seq }` row 旁记录来源类别与到达 revision。最新到达的 list hint 会替换暂定 row,即使崩溃修复降低了其 watermark;完整权威 cut 会阻止之后的 hint。首个权威 frame 无论 sequence 如何都会替换暂定 row,后续 frame 则必须具有严格更高的 sequence。follow baseline 会替换整个 store;control baseline 会精确替换已包含的 Session,并移除其中缺失的 key。 +Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。 -每个 Session 为初次打开、显式 resync 或 carrier 重连保留一个不透明 token,并在该 opening 失败或被取代时取消它。当 follow baseline 完成该 token 时,store 丢弃 token 之前的状态,只保留 token 之后到达且 seq 新于 opening cut 的权威 frame。若 control baseline 在 token 之后到达且 cut 等于或新于 opening cut,它保持权威。Session 不捕获、缓冲或重放 projection operation。 +List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 -List view 与已打开 Session 读取同一个 per-Session store。首次完整权威 cut 之前,hint 可以填充 title、preset 和其他 list presentation。若后续 control generation 缺失该 Session,`SessionManager` 会使上一代权威 row 失效,并把最近保留的 list block 重新安装为暂定值。list pull 会把稍后到达的 add 与 remove mutation 折叠到请求时的 hint 上,因此延迟响应无法逆转到达顺序。store 从不折叠 Session event:它拥有 hint/frame 来源、frame 排序、完整 baseline 的精确替换和 opening reconciliation;`Session` 只拥有 token 生命周期,`SessionManager` 则把 list hint、control frame 与 control baseline 路由到这份 resident store。 +每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。 -不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 +不由单个 Session 派生的数据不进入 projection。`session/modelCatalog` 持有当前 Host generation 的 model catalog,`agentPresets/list` 持有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 Client 本地交互状态也继续留在本地:loading 和 error 状态、打开的菜单、进行中的选择,以及为尚未创建 Session 暂存的选择都不是可回放 Session 事实。选择一旦应用到 Session,其持久事件与 projection 就成为权威。 @@ -146,8 +146,8 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 | 精确 live-preferred read cut | SessionQuery observation | 各 endpoint helper | | Fold state 与 Client-value 计算 | Projection registry 与 domain unit | SessionQuery 与 Client | | 部分 list acceleration | Projection cache 与 list policy | Follow protocol | -| Opening 与 reconnect token 生命周期 | Client Session | Session page 与 domain UI component | -| Hint、frame 与完整 baseline 的 precedence | Client projection store | Client Session 与 domain UI component | +| Opening 与 reconnect replacement | Session follow 与 journal stream | Session page | +| Per-key value ordering | Client projection store | Domain UI component | | Provider 或 preset catalog lifecycle | 对应 catalog directory | Session projection | | Rendering 与瞬时 interaction state | Domain UI package | Host projection unit | @@ -174,7 +174,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。 -Client 测试固定按到达顺序处理暂定 hint、首个权威 frame 接管、opening 与等 cut control 精确替换、list/control 两种到达顺序下的 cold Session 缺失、进行中 list mutation 重放、token 后 frame 保留、陈旧或已取消的 opening token、manager/Session 共用 store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。 +Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml index c7d62f839f..478178ef58 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.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 .agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md -2026-08-25-sparse-first-party-prompt-section-orders.md: 2bf2e7441b449a6cfbd5b845f1d7e97b3fab09ae -2026-08-25-sparse-first-party-prompt-section-orders.zh.md: 624ed51f4d40848c72091097900ef05ec35fdd2e +2026-08-25-sparse-first-party-prompt-section-orders.md: 4b2568f18d104a77d00f91bb98f64047ecd85fa9 +2026-08-25-sparse-first-party-prompt-section-orders.zh.md: 4dfb0d0bd87ff5f245c28f3dbab6a48ba898f924 diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md index 2bf2e7441b..4b2568f18d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md @@ -22,7 +22,7 @@ The allocation preserves the established first-party sequence except for two del |---|---| | Product opening | `harness:identity` −1000, `harness:source` −900, `app:web-surface` −800, `deployment:persona` 0 | | Work modes | `plan:policy` 500, `team:policy` 600 | -| Invocation prelude | `tools:code-only` 800, `context:file-reference` 900 | +| Invocation prelude | `tools:ptc-only` 800, `context:file-reference` 900 | | Local tools | `tool:bash` 1000, `tool:pwsh` 1010, `tool:read` 1100, `tool:write` 1200, `tool:edit` 1300, `tool:glob` 1400, `tool:grep` 1500, `tool:jobs` 1600, `tool:pty` 1700 | | Higher-level tools | `tool:web_search` 2000, `tool:web_fetch` 2100, `tool:lsp` 2200, `tool:session-query` 2300, `tool:goal` 2400, `tool:cordis` 2500, `tool:workflow` 2600, `tool:ralph` 2700, continuable-subagent guidance 2800, `tool:report` 2900 | | Generated protocol | `tools:sdk` 5000 | diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md index 624ed51f4d..4dfb0d0bd8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md @@ -22,7 +22,7 @@ Status: implemented |---|---| | 产品开场 | `harness:identity` −1000、`harness:source` −900、`app:web-surface` −800、`deployment:persona` 0 | | 工作模式 | `plan:policy` 500、`team:policy` 600 | -| 调用前置说明 | `tools:code-only` 800、`context:file-reference` 900 | +| 调用前置说明 | `tools:ptc-only` 800、`context:file-reference` 900 | | 本地工具 | `tool:bash` 1000、`tool:pwsh` 1010、`tool:read` 1100、`tool:write` 1200、`tool:edit` 1300、`tool:glob` 1400、`tool:grep` 1500、`tool:jobs` 1600、`tool:pty` 1700 | | 高层工具 | `tool:web_search` 2000、`tool:web_fetch` 2100、`tool:lsp` 2200、`tool:session-query` 2300、`tool:goal` 2400、`tool:cordis` 2500、`tool:workflow` 2600、`tool:ralph` 2700、可继续运行的 subagent 指导 2800、`tool:report` 2900 | | 生成协议 | `tools:sdk` 5000 | diff --git a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml new file mode 100644 index 0000000000..b175565b1d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.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/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md +2026-08-26-inspector-execution-realms-and-protocol-planes.md: e8bff0661d2d0c86c216b0a18e2feb7a2c786709 +2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 2db6307d1bfc536ac5e8b0f5d6f03e4cfe334989 diff --git a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md new file mode 100644 index 0000000000..e8bff0661d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md @@ -0,0 +1,96 @@ +# Agent Note: Inspector execution realms and protocol planes + +Status: implemented + +English | [中文](2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md) + +## Problem + +The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Without execution-oriented directories, feature names alone do not establish where code runs or which identifiers it may own. + +This ambiguity is risky because Host and Client support intentionally differs while their architecture must remain comparable. Host Runtime and Debugger delegate to Node's inspector protocol; Client Runtime and Console simulate the same backend semantics over an internal bridge. If their files, interfaces, and unsupported operations diverge structurally, each new protocol method encourages a second routing model. Likewise, consumers that only need the Cordis runtime tree must not inherit debugger activation, Chrome connection state, or CDP identifiers. + +The [cross-realm CDP inspector decision](2026-08-23-cross-realm-cdp-inspector.md) owns Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](2026-08-24-cordis-runtime-tree-inspection.md) owns Cordis tree semantics, object routing, and DOM projection. This decision owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state. + +## Decision + +Top-level source directories identify execution ownership. `client/` contains only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client belongs under `worker/`, not `client/`. + +The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling. + +```text +src/ + shared/ environment-independent data and interfaces + client/ browser Client producer and adapters + host/ Host Node-main-thread producer and adapters + worker/ Worker transport, repositories, realm backends, and CDP endpoint +``` + +`client/` and `host/` have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support. + +Worker-side realm adapters use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers. + +## Execution ownership + +`client/` owns page-realm observation, Client object handles, browser evaluation, browser Console interception, Client source publication, and its direct authenticated bridge to the Worker. It may use browser APIs but not Node or Worker implementation modules. + +`host/` owns Cordis plugin composition on the Node main thread, Worker startup and disposal, Host object observation, fetch capture, Node inspector notification forwarding, and the Host side of the Worker bridge. It may use Node APIs but does not construct Chrome CDP responses. + +`worker/bridge/` owns source admission, transport endpoints, connection generations, frame dispatch, correlation, and routing between source producers and Worker consumers. `worker/inspection/` owns retained Cordis and network observations plus transport-independent queries. `worker/realms/` owns the normalized Host and Client runtime backends. `worker/cdp/` owns HTTP discovery, DevTools sessions, Chrome method dispatch, domain enable state, and every connection-local Chrome identifier. + +The Worker remains the sole Chrome CDP wire and state owner. Client code simulates shared backend operations, not the CDP wire. Host code delegates supported backend operations to Node inspector, but Node protocol identifiers are translated inside the Worker Host realm before common domain projection. + +## Data and identifier ownership + +`shared/cordis/` contains the CDP-independent semantic model, immutable snapshots, collection and observation, realm-local object registration, projections, and reader interfaces. `model.ts` contains no transport handles or CDP identifiers. `snapshot.ts` may carry a realm-local opaque object reference because a live object query needs that route, but consumers can project it away. + +`shared/network/` contains fetch and network observations, captured body representation, and header normalization. These records describe observed activity and do not contain CDP request ids or domain enable state. + +`shared/cdp/` contains normalized backend interfaces and values for realm capabilities, Runtime, Console, Debugger, Sources, Profiler, HeapProfiler, and typed unsupported results. Backend handles in these interfaces are opaque and realm-owned. They are not Chrome `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, or `CallFrameId` values. + +`shared/bridge/` contains the versioned internal carrier: source and generation identifiers, envelopes, codecs, validation, bounded publication, RPC correlation, dispatch interfaces, and domain-specific message unions. Its message modules may transport Cordis snapshots, network observations, Console events, Runtime operations, source reads, debugger operations, and semantic queries without turning those values into CDP messages. + +`worker/cdp/ids.ts` is the only owner of Chrome connection-local identifiers such as `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, `NodeId`, and `CallFrameId`. Worker domain sessions allocate and release them and map them to realm backend handles or inspection records. Source, generation, sequence, request, Cordis Fiber uid, realm object reference, backend handle, and Chrome id remain distinct types because their owners and lifetimes differ. + +## Dependency rules + +The domain modules `shared/cordis/`, `shared/network/`, and `shared/cdp/` do not import `shared/bridge/` or any execution-specific directory. `shared/bridge/` may import those domain types when defining internal messages. No module under `shared/` imports Node-only or browser-only APIs. + +Top-level `client/` and `host/` import `shared/` but never each other or `worker/`. Equivalent roles use equivalent shared interfaces. Environment-specific transport and engine behavior stays in the mirrored implementation file rather than entering a shared conditional implementation. + +`worker/realms/` and `worker/inspection/` import shared interfaces but do not import `worker/cdp/`; normalized backend results and stored observations cannot contain Chrome connection state. `worker/cdp/` may consume realm and inspection interfaces to project CDP. `worker/bridge/` routes shared messages and invokes Worker services without becoming an owner of Cordis, network, Runtime, or Chrome state. + +The package remains one `@deepseek-ai/dsh-experimental-inspector` package with explicit Client and Host compiler faces. Directory separation is an execution and dependency rule, not a package split. + +## Verification + +- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root. +- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed. +- Cordis and network readers are usable without importing debugger, source, transport, or CDP session modules. +- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids. +- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages. +- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state. +- Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior have focused coverage. +- Compiler faces, import checks, and the structural layout test reject environment leaks and Client/Host mirror drift. + +## Alternatives considered + +**Organize every file by feature domain.** Rejected because a Runtime or Cordis feature spans three environments with different available APIs. Feature-only paths conceal execution constraints and make accidental browser-to-Node imports difficult to review. + +**Put Worker Client and Host adapters in top-level `client/` and `host/`.** Rejected because those adapters execute in the Worker and own different resources from page and Node-main-thread producers. A directory name must answer where code runs before it answers which remote realm it represents. + +**Allow Client and Host trees to contain only currently supported files.** Rejected because asymmetric layout obscures missing capability decisions and lets equivalent routing roles acquire unrelated interfaces. Explicit unsupported implementations keep evolution exhaustive without pretending support exists. + +**Keep one shared protocol directory.** Rejected because internal carrier identities, Cordis semantic data, normalized Runtime values, and Chrome wire identifiers have different consumers and lifetimes. A single directory encourages domain models to depend on transport and CDP presentation. + +**Split Client, Host, protocol, and Worker into separate packages.** Rejected for the experimental phase. The deployment unit remains one Client/Host Cordis plugin, and package boundaries would add build and release coordination without improving the required execution separation. + +## Consequences + +Exact mirroring adds small adapter files for unsupported capabilities. Those files are intentional compatibility points between implementations, but they must stay thin and must not manufacture fake behavior. + +Moving types without changing behavior can still expose hidden dependency cycles, especially where Runtime object annotation reaches Cordis repositories. The dependency rules require inversion through shared interfaces rather than a temporary import from a lower-level module. + +`shared/cdp/` can become a second copy of the Chrome protocol if normalized types are added indiscriminately. A shared type belongs there only when both realm implementations or a common Worker projector consume it; Chrome session bookkeeping and wire-only fields remain under `worker/cdp/`. + +Explicit Client and Host compiler faces and focused behavior tests add maintenance work, but they keep environment leaks and mirror drift visible. diff --git a/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md new file mode 100644 index 0000000000..2db6307d1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md @@ -0,0 +1,96 @@ +# Agent Note: Inspector 执行环境与协议平面 + +Status: implemented + +[English](2026-08-26-inspector-execution-realms-and-protocol-planes.md) | 中文 + +## Problem + +Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。只按功能命名目录时,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。 + +这种含糊会带来风险,因为 Host 与 Client 的支持能力有意不同,但架构必须保持可比较。Host Runtime 与 Debugger 委托 Node inspector protocol;Client Runtime 与 Console 通过内部 bridge 模拟同一套 backend 语义。如果两边的文件、接口与 unsupported operation 在结构上分叉,每增加一种协议方法都容易产生第二套路由模型。同样,只需要 Cordis 运行时树的消费方不应继承 debugger activation、Chrome 连接状态或 CDP 标识符。 + +现有的[跨 realm CDP Inspector 决策](2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](2026-08-24-cordis-runtime-tree-inspection.zh.md)负责 Cordis 树语义、对象路由与 DOM projection。本决策负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。 + +## Decision + +顶层源码目录标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。 + +仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。 + +```text +src/ + shared/ environment-independent data and interfaces + client/ browser Client producer and adapters + host/ Host Node-main-thread producer and adapters + worker/ Worker transport, repositories, realm backends, and CDP endpoint +``` + +`client/` 与 `host/` 拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。 + +Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。 + +## Execution ownership + +`client/` 负责 page realm observation、Client object handle、浏览器求值、浏览器 Console interception、Client source publication 以及到 Worker 的直接鉴权 bridge。它可以使用浏览器 API,但不能导入 Node 或 Worker 实现模块。 + +`host/` 负责 Node 主线程上的 Cordis plugin composition、Worker 启动与 dispose、Host object observation、fetch capture、Node inspector notification forwarding,以及 Worker bridge 的 Host 一侧。它可以使用 Node API,但不构造 Chrome CDP response。 + +`worker/bridge/` 负责 source admission、transport endpoint、connection generation、frame dispatch、correlation,以及 source producer 与 Worker consumer 之间的路由。`worker/inspection/` 负责保留的 Cordis 与 network observation,以及不依赖 transport 的 query。`worker/realms/` 负责规范化的 Host 与 Client runtime backend。`worker/cdp/` 负责 HTTP discovery、DevTools session、Chrome method dispatch、domain enable 状态与所有连接局部的 Chrome 标识符。 + +Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模拟共享 backend operation,而不是模拟 CDP wire。Host 代码把支持的 backend operation 委托给 Node inspector,但 Node protocol 标识符在 Worker Host realm 内转换后才进入公共 domain projection。 + +## Data and identifier ownership + +`shared/cordis/` 包含与 CDP 无关的语义模型、不可变 snapshot、collection 与 observation、realm-local object registration、projection 与 reader interface。`model.ts` 不包含 transport handle 或 CDP 标识符。`snapshot.ts` 可以携带 realm-local opaque object reference,因为实时对象查询需要该路由信息,但消费方可以在 projection 中移除它。 + +`shared/network/` 包含 fetch 与 network observation、采集 body 表示及 header normalization。这些记录描述已观测活动,不包含 CDP request id 或 domain enable 状态。 + +`shared/cdp/` 包含 realm capability、Runtime、Console、Debugger、Sources、Profiler、HeapProfiler 的规范化 backend 接口和值,以及类型化 unsupported 结果。这些接口中的 backend handle 是不透明且由 realm 持有的。它们不是 Chrome `RemoteObjectId`、`ExecutionContextId`、`ScriptId` 或 `CallFrameId`。 + +`shared/bridge/` 包含带版本的内部 carrier:source 与 generation 标识符、envelope、codec、validation、有限 publication、RPC correlation、dispatch interface 及分领域的 message union。其 message 模块可以传输 Cordis snapshot、network observation、Console event、Runtime operation、source read、debugger operation 与语义 query,但不会把这些值转换成 CDP message。 + +`worker/cdp/ids.ts` 是 Chrome 连接局部标识符的唯一 owner,包括 `RemoteObjectId`、`ExecutionContextId`、`ScriptId`、`NodeId` 与 `CallFrameId`。Worker domain session 分配并释放这些 id,把它们映射到 realm backend handle 或 inspection record。source、generation、sequence、request、Cordis Fiber uid、realm object reference、backend handle 与 Chrome id 必须保持为不同类型,因为它们的 owner 和生命周期不同。 + +## Dependency rules + +领域模块 `shared/cordis/`、`shared/network/` 与 `shared/cdp/` 不导入 `shared/bridge/` 或任何执行环境专属目录。`shared/bridge/` 在定义内部 message 时可以导入这些领域类型。`shared/` 下的任何模块都不导入 Node-only 或 browser-only API。 + +顶层 `client/` 与 `host/` 可以导入 `shared/`,但不能互相导入,也不能导入 `worker/`。等价角色使用等价的共享接口。环境专属 transport 与 engine 行为保留在对应镜像实现文件中,不进入带条件分支的共享实现。 + +`worker/realms/` 与 `worker/inspection/` 可以导入共享接口,但不导入 `worker/cdp/`;规范化 backend result 和已存 observation 不能包含 Chrome connection state。`worker/cdp/` 可以消费 realm 与 inspection interface 来生成 CDP projection。`worker/bridge/` 路由共享 message 并调用 Worker service,但不成为 Cordis、network、Runtime 或 Chrome 状态的 owner。 + +本能力继续保留在同一个 `@deepseek-ai/dsh-experimental-inspector` 包中,并使用显式 Client 与 Host compiler face。目录分隔是执行与依赖规则,不是拆包方案。 + +## Verification + +- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。 +- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。 +- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。 +- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。 +- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。 +- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。 +- Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为均有聚焦测试覆盖。 +- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。 + +## Alternatives considered + +**所有文件都按功能领域组织。** 拒绝,因为一个 Runtime 或 Cordis 功能会跨越三个可用 API 不同的环境。只有功能信息的路径会隐藏执行限制,也让 browser 到 Node 的意外导入难以审查。 + +**把 Worker Client 与 Host adapter 放入顶层 `client/` 和 `host/`。** 拒绝,因为这些 adapter 实际运行在 Worker 中,持有的资源也不同于 page 与 Node 主线程 producer。目录名应先回答代码在哪里运行,再回答它代表哪个远端 realm。 + +**Client 与 Host 目录只保留当前支持的文件。** 拒绝,因为不对称目录会隐藏缺失能力决策,并允许等价路由角色形成无关接口。显式 unsupported 实现既保证穷尽演进,也不虚构已支持行为。 + +**保留一个共享 protocol 目录。** 拒绝,因为内部 carrier identity、Cordis 语义数据、规范化 Runtime value 与 Chrome wire identifier 的消费方和生命周期不同。单一目录会诱导领域模型依赖 transport 和 CDP presentation。 + +**把 Client、Host、protocol 与 Worker 拆成多个包。** 实验阶段拒绝。部署单元仍是一个 Client/Host Cordis plugin;包边界会增加构建和发布协作,却不能改善所需的执行环境分隔。 + +## Consequences + +严格镜像会为不支持的能力增加小型 adapter 文件。这些文件是两个实现之间有意保留的兼容点,但必须保持轻薄,也不能制造虚假行为。 + +即使只移动类型而不改变行为,也可能暴露隐藏的依赖环,尤其是 Runtime object annotation 访问 Cordis repository 的位置。依赖规则要求通过共享接口反转依赖,不能临时从较低层模块反向导入。 + +如果不加约束地添加规范化类型,`shared/cdp/` 可能变成第二份 Chrome protocol。只有两个 realm 实现或公共 Worker projector 会消费的类型才属于这里;Chrome session bookkeeping 与 wire-only field 保留在 `worker/cdp/`。 + +显式 Client/Host compiler face 与聚焦行为测试增加了维护工作,但会持续暴露环境泄漏和镜像结构漂移。 diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml new file mode 100644 index 0000000000..10d18004d1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.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/architecture/2026-08-26-local-submission-echoes.md +2026-08-26-local-submission-echoes.md: 258df8f3f4bdd0441b714c7602273897d2d79fc7 +2026-08-26-local-submission-echoes.zh.md: 57a9523a1eaa399e596f0c8eb6f8913545976f87 diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md new file mode 100644 index 0000000000..258df8f3f4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md @@ -0,0 +1,37 @@ +# Agent Note: Local submission echoes over the prompt rpcId + +Status: implemented + +English | [中文](2026-08-26-local-submission-echoes.zh.md) + +## Problem + +A multi-image prompt spent seconds in client serialization plus host admission before its durable `user/message` existed, and the conversation showed nothing until then: the composer froze read-only, the message appeared only after the full pipeline, and the user could not tell whether the submission had started (#3003). The durable event cannot move earlier — Model-visible ⟺ logged requires the `user/message` to land only after every attachment persists — so the visible submission had to decouple from the durable one. + +## Decision + +**The Session object owns a client-local submission echo, correlated by the prompt's existing `requestId`/`rpcId`.** `session.beginSubmission` synchronously inserts `{requestId, text, images: previews}` into `SessionSnapshot.pendingSubmissions` and flips `promptAttempted`, before the caller serializes anything; the same `requestId` rides the prompt RPC. No new correlation id, no wire-type change, and no session-log change: the host already stamps the prompt's `requestId` into the durable user source as `rpcId`, and the queue projection now carries it as `SessionQueuedItem.rpcId` for prompts that land in the inbox instead of the log (running-turn submissions). + +**Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins. + +**The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). Failed detached sends are restored together in submission order while the composer is empty or still contains the preceding automatic restoration; a user edit ends that restoration sequence. Draft images remain owned by the detached attempt through echo retirement, so Session scope disposal can release them after they have left the rail. An observed echo gives each preview URL to `HistoricalImageCache.seed` under the admitted reference. The cache exposes that preview synchronously, fetches the durable attachment, replaces the preview with the canonical URL, and revokes both URLs with their respective lifetimes. Direct subagent continuations do not register echoes because their transport assigns a different RPC identity and image input is unsupported. + +Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here. + +## Consequences + +The submit click paints its message and docks the composer on the same frame, for ordinary text and image prompts, while admission timing is unchanged. The composer never freezes for default sends, so drafts can be typed and sent during a flight; the machine's `submitting` phase now occurs only for command submissions. A prompt whose RPC response is lost but whose admission succeeded converges through observation instead of double-posting. An admitted image keeps its local preview until the durable bytes resolve, then displays the host-authoritative rendition without a loading placeholder. + +## Verification + +Session client specs pin synchronous insertion, requestId threading, event/queue/window observation, one retirement when queue and durable observations coincide, frame-delayed removal, abandon, and disposal. Machine and shell specs pin optimistic commit, concurrent detached settlement, ordered multi-failure restoration, image-only cancellation, and image ownership through scope disposal. ChatView specs pin flow-tail rendering and node- and queue-keyed dedupe with the echo still in the snapshot. Host control specs pin the queue rpcId projection; cache and attachment specs pin synchronous seeded display, canonical replacement, and URL revocation. The connection fixture echoes `requestId`, and the `fresh-round-trip` recorded-session snapshot captures the local echo before durable admission. + +## Alternatives considered + +**A new `clientSubmissionId` threaded through the wire and the user source.** Rejected: `requestId` already exists end-to-end (`user-rpc` source member), so a second id would duplicate the correlation and touch wire validation for nothing. + +**Retire the echo synchronously on event ingestion.** Rejected: the chat assembly publishes on an animation frame, so synchronous removal blanks the message for a frame. The steering queue mirror historically accepted that race; the echo path removes it via render-time dedupe plus the delayed retirement. + +**Render echoes through the conversation assembler as synthetic nodes.** Rejected: the assembler is driven by durable session events only, and a client-only node kind would widen the closed `ConversationNode` union into every target's `assertNever`; the `PartialAssistant`-style side-channel state matches the existing precedent. + +**Keep the composer frozen and only add the echo.** Rejected: the issue's acceptance requires consecutive and concurrent submissions, and a frozen composer reintroduces the perceived hang the echo exists to remove. diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md new file mode 100644 index 0000000000..57a9523a1e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md @@ -0,0 +1,37 @@ +# Agent Note:基于 prompt rpcId 的本地提交回显 + +状态:implemented + +[English](2026-08-26-local-submission-echoes.md) | 中文 + +## 问题 + +多图 prompt 在客户端序列化加 host admission 上要花数秒,durable `user/message` 在此之前不存在,会话在此期间什么都不显示:composer 冻结为只读,消息在整条流水线结束后才出现,用户无法判断提交是否已经开始(#3003)。durable event 无法提前,Model-visible ⟺ logged 要求 `user/message` 只能在全部附件持久化后落盘,因此可见的提交必须与 durable 的提交解耦。 + +## 决定 + +**Session 对象持有客户端本地的提交回显,用 prompt 现有的 `requestId`/`rpcId` 关联。**`session.beginSubmission` 在调用方序列化任何内容之前,同步把 `{requestId, text, images: previews}` 写入 `SessionSnapshot.pendingSubmissions` 并翻转 `promptAttempted`;同一个 `requestId` 随 prompt RPC 发出。没有新关联 id,没有 wire 类型改动,也没有 session log 改动:host 本就把 prompt 的 `requestId` 写进 durable user source 的 `rpcId`,queue 投影现在把它作为 `SessionQueuedItem.rpcId` 携带,覆盖落进 inbox 而非 log 的 prompt(运行中 turn 的提交)。 + +**退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。 + +**Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行,允许并发发送,唯一的冻结 in-flight 槽只留给命令。多个 detached 发送失败时,只要 composer 为空或仍是上一次自动还原的内容,就按提交顺序合并还原;用户编辑后停止这一轮自动还原。草稿图片由 detached attempt 持有到回显退休,因此图片离开 rail 后销毁 Session scope 仍能释放它们。回显以 observed 退休时,`HistoricalImageCache.seed` 把每个预览 URL 挂到 admitted 引用名下。缓存同步公开预览 URL,同时读取 durable 附件;读取完成后用规范化 URL 替换预览,并按各自生命周期撤销两个 URL。直接 subagent continuation 不注册回显,因为它的 transport 会分配另一个 RPC id,而且不支持图片输入。 + +客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。 + +## 后果 + +普通文本与图片 prompt 点击提交后会在当帧显示消息并让 composer 落底,admission 时机不变。默认发送不再冻结 composer,发送期间可以继续输入和提交;machine 的 `submitting` 阶段只用于命令提交。RPC 响应丢失但 admission 已成功的 prompt 通过观察确认结果,不会重复发送。图片在 durable 字节返回前显示本地预览,随后显示 host 保存的版本,中间没有加载占位。 + +## 验证 + +Session client spec 覆盖同步插入、requestId 透传、event、queue 与窗口观察、queue 和 durable 同时观察时只退休一次、延帧移除、abandon 与销毁。Machine 与 shell spec 覆盖乐观提交、并发 detached settlement、多个失败按提交顺序还原、图片纯发送的取消,以及图片随 scope 销毁而释放。ChatView spec 覆盖流尾渲染,以及回显仍在 snapshot 时按节点和队列去重。Host control spec 覆盖 queue rpcId 投影;缓存与附件 spec 覆盖 seed 首帧显示、规范化替换和 URL 撤销。connection fixture 回显 `requestId`,`fresh-round-trip` 的 recorded-session snapshot 在 durable admission 前记录本地回显。 + +## 考虑过的替代方案 + +**新增 `clientSubmissionId` 贯穿 wire 与 user source。**否决:`requestId` 已端到端存在(`user-rpc` source 成员),第二个 id 会重复关联并平白触碰 wire 校验。 + +**事件入库时同步移除回显。**否决:会话组装按动画帧发布,同步移除会让消息空一帧。steering 队列镜像历史上接受了这个竞态;回显路径用渲染期去重加延帧退休消除它。 + +**把回显作为合成节点走会话 assembler。**否决:assembler 只由 durable session event 驱动,客户端专属的节点 kind 会把闭合的 `ConversationNode` 联合扩进每个 target 的 `assertNever`;`PartialAssistant` 式的旁路状态符合现有先例。 + +**保持 composer 冻结,只加回显。**否决:issue 验收要求连续与并发提交,冻结的 composer 会重新引入回显本要消除的卡顿感。 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.i18n.yaml index 0ed1b87308..7fc0cd1b35 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.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 .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md -2026-07-20-code-mode-typed-tool-returns.md: a8a251f5f0d39f4deedc42e08eb45c2b5fa11807 -2026-07-20-code-mode-typed-tool-returns.zh.md: 2589d63d3fb900c88fb15ab68107e75735d1be44 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md +2026-08-27-inspector-development-mount.md: a8660c664f6fba49369af075bc998171a454a842 +2026-08-27-inspector-development-mount.zh.md: 3dd82cc89e4c172c3ff9a4f8dc4cafd47c9fd847 diff --git a/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md new file mode 100644 index 0000000000..a8660c664f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md @@ -0,0 +1,26 @@ +# Agent Note: Inspector development mount + +Status: implemented + +English | [中文](2026-08-27-inspector-development-mount.zh.md) + +## Problem + +`@deepseek-ai/dsh-experimental-inspector` is a private package no published dsh installation carries, yet development launches need to mount it into the shipped Web composition on demand. A row in a shipped bundle patch cannot express this: `verify-cordis-config` requires every named row of a bundle patch to resolve from that bundle's own `dependencies` — disabled rows included — and a published manifest must not depend on an unpublished package. + +## Decision + +The inspector package owns two development overlays. `packages/experimental/inspector/cordis.source.patch.yml` inserts `./src/index.ts` for the tsx source launch behind `pnpm run demo:inspector`. `packages/experimental/inspector/cordis.patch.yml` inserts `./lib/index.js` for `node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml` after `pnpm run build`. + +Each relative entry resolves from its overlay file's directory through the Loader's normal owning-tree `baseUrl`. The source launch therefore reaches TypeScript directly, while the built launch reaches the package artifact; neither path reads or modifies profile-installed plugin state. A missing source or built entry fails loud during Loader import rather than skipping the Inspector. + +## Consequences + +Published packages carry no trace of the inspector: no manifest entry, no composition row, no launcher flag. Mounting stays a per-launch choice — the same service without an overlay never loads the package — and every layer the launch composes is declared in a config file. The source shorthand names its overlay automatically; a built launch names the built overlay explicitly and requires current `lib/` artifacts. + +## Alternatives considered + +- A `disabled: !!js` row in the shipped web-app patch: the dependency gate and npm publication both force the private package into the published manifest. +- A `--inspector` launcher flag mounting the package as an extra bundle layer: the launcher owns neither app flags nor plugin package names. +- An optional `peerDependencies` entry on `dsh-web-app` plus a dynamic `ctx.loader.create` from its glue plugin: it writes a never-published name into a published manifest and mounts a row no config layer declares. +- One bare-package overlay for both launch modes: source resolution can use the workspace facade, but built resolution would require persistent profile installation state unrelated to the launch command. diff --git a/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md new file mode 100644 index 0000000000..3dd82cc89e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md @@ -0,0 +1,26 @@ +# Agent Note:Inspector 开发挂载 + +Status: implemented + +[English](2026-08-27-inspector-development-mount.md) | 中文 + +## Problem + +`@deepseek-ai/dsh-experimental-inspector` 是任何已发布 dsh 安装都不携带的 private 包,但开发启动需要按需把它挂进随货 Web 组合。随货 bundle patch 里的一行表达不了这件事:`verify-cordis-config` 要求 bundle patch 中每个具名行都能从该 bundle 自己的 `dependencies` 解析——disabled 行也不豁免——而已发布的 manifest 不得依赖未发布的包。 + +## Decision + +inspector 包自有两份开发 overlay。`packages/experimental/inspector/cordis.source.patch.yml` 为 `pnpm run demo:inspector` 背后的 tsx 源码启动插入 `./src/index.ts`;`packages/experimental/inspector/cordis.patch.yml` 为执行过 `pnpm run build` 后的 `node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml` 插入 `./lib/index.js`。 + +两个相对 entry 都通过 Loader 常规的所属 tree `baseUrl` 从各自 overlay 文件目录解析。源码启动因此直接读取 TypeScript,built 启动读取包产物;两条路径都不读取或修改 profile 已安装插件状态。源码或 built entry 缺失时,Loader import 会响亮失败,不会跳过 Inspector。 + +## Consequences + +已发布的包不携带 inspector 的任何痕迹:没有 manifest 条目、没有组合行、没有 launcher flag。挂载保持按次启动选择——不带 overlay 的同一服务永远不会加载该包——且启动组合的每一层都由 config 文件声明。源码快捷命令会自动指定对应 overlay;built 启动需显式指定 built overlay,并要求 `lib/` 产物为当前版本。 + +## Alternatives considered + +- 随货 web-app patch 里放 `disabled: !!js` 行:依赖门禁与 npm 发布都会把 private 包逼进已发布 manifest。 +- `--inspector` launcher flag 把包挂成额外 bundle 层:launcher 既不拥有 app flag 也不拥有插件包名。 +- `dsh-web-app` 上加 optional `peerDependencies` 并由其 glue 插件动态 `ctx.loader.create`:向已发布 manifest 写入永不发布的名字,且挂载的行不在任何 config 层声明。 +- 两种启动模式共用一份 bare-package overlay:源码解析可以使用 workspace 门面,但 built 解析会依赖与本次启动命令无关的持久 profile 安装状态。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml new file mode 100644 index 0000000000..138ca27a49 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.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/architecture/2026-08-27-process-table-snapshots.md +2026-08-27-process-table-snapshots.md: 27c364607ca1e03a926c309f26007477a8785636 +2026-08-27-process-table-snapshots.zh.md: 9c5fe65bb83a0d04fe5639b3ffefcf377c3588ef diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md new file mode 100644 index 0000000000..27c364607c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md @@ -0,0 +1,71 @@ +# Agent Note: Process-table snapshots replace per-question inspector reads + +Status: implemented + +English | [中文](2026-08-27-process-table-snapshots.zh.md) + +## Problem + +A terminal readiness poll asks the platform three questions: the shell's descendant tree, its POSIX session membership, and whether each tracked descendant is still running. When each question reads the process table independently, the poll's cost scales with the number of descendants the running command spawned. + +On macOS each read is a `/bin/ps -axo` fork that parses the entire table — 14.33 ms for 795 processes on the measured host. `LocalTerminalHandle.inspectForeground()` reads the tree once and then asks liveness once per tracked descendant, so one poll costs N+1 table reads for N descendants. `dsh-terminal-bash` polls every 50 ms for up to 30 s, and `ProcessInspectorInternals.exec` is `execFileSync`, so each poll blocks the event loop for its full duration. + +Measured by driving the production `MacProcessInspector` against a real process tree: + +| tracked descendants | one poll | share of the 50 ms interval | +|---|---|---| +| 0 | 18.0 ms | 36% | +| 1 | 33.8 ms | 68% | +| 2 | 49.1 ms | 98% | +| 5 | 87.1 ms | 174% | +| 10 | 178.4 ms | 357% | + +Any command spawning two or more children — a pipeline, `make`, `pnpm`, `git` — saturates the host event loop until it exits. + +Teardown has the same structure. `signalProcess` fences each signal against PID reuse by asking liveness itself, so signalling N members costs N table reads. + +## Decision + +`ProcessInspector.snapshot()` returns a `ProcessSnapshot`, one observation of the process table that answers `tree(rootPid)`, `session(sessionId)`, and `alive(identity)`. It replaces the three per-question methods; the inspector's remaining surface is `foregroundPgid`, `isStdinWaiting`, `signalGroup`, and `signalProcess`. + +Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change. + +Signalling does not share that observation. `ProcessInspector.isAlive(identity)` answers current state from the narrowest per-identity source a platform offers — one `/proc//stat` read on Linux, one `ps` table on macOS, one process-handle check on Windows — and `signalProcess` takes that fence immediately before delivering the signal. An observation cannot stand in for it: the observation preserves the original PID-to-start-time pairing, so a recycled PID would still match it and take a signal meant for the process that exited. Reading the fence per target also keeps a failed read costing one target instead of the rest of a teardown round, which is what the [synchronous exit-cleanup contract](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) requires. + +`signalMembers` and `waitForMembers` return before capturing anything when a round has no members, so a command that spawned no descendants pays no table read for its teardown sweeps. + +Platform differences live in how a snapshot is built, not in what it promises: + +- **macOS** builds it from one `ps` table. That table exposes neither a session id nor a state column, so `session` is empty and `alive` reports presence with a matching start identity. +- **Linux** walks `/proc` once, carrying each entry's parent, start identity, session, and state. `alive` treats the `Z`, `X`, and `x` states as quiescent, as a per-pid `stat` read did. +- **Windows** enumerates Toolhelp32 lazily, on the first `tree` question. It has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there — so a snapshot asked only for liveness never enumerates. The terminal's Windows teardown polls liveness every 25 ms and would otherwise walk and discard the whole table each time. + +`PosixProcessSnapshot` holds both POSIX shapes: a row's `session` and `state` are `undefined` where the platform's table omits them, which is what makes the macOS answers fall out of the shared implementation instead of a second class. + +## Testing + +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` drives a real `MacProcessInspector` over an injected `exec` and asserts one foreground inspection performs exactly one `-axo` table read at 0, 2, and 10 descendants. That count, not wall time, is the durable invariant: it holds on any host and fails the moment a caller re-reads the table per member. The same file pins that a signalling round with no members captures nothing, and that a capture failure during synchronous host exit still lets the PTY root be killed. + +`process-inspector.spec.ts` pins the fence directly: an identity observed alive and then absent from the table takes no signal. `windows-inspector.spec.ts` pins that a snapshot answering only liveness performs no Toolhelp32 enumeration. + +## Alternatives considered + +**A batched `aliveMembers(members)` call, leaving the other methods alone.** This collapses the per-member reads and is a much smaller edit, but the tree read stays separate, so a macOS poll still forks `ps` twice plus the `tpgid` read — about 32 ms at 10 descendants, still 64% of the 50 ms interval. The event loop remains mostly blocked, so the measured problem survives the fix. + +**Caching the macOS table inside `MacProcessInspector` behind a short TTL.** This needs no interface change, but it makes staleness invisible: a caller cannot tell whether a liveness answer came from this instant or from the end of the previous poll, and a signal decided on a stale row is exactly what the PID-reuse fence exists to prevent. Hidden caching also conflicts with the repository's preference for explicit defaulting and explicit boundaries. + +**Fencing signals with the round's shared observation.** This removes the last per-member read and was the shape first implemented here. Review rejected it: the fence exists to defeat PID reuse, and an observation defeats the fence instead, because it carries the original PID-to-start-time pairing forward. The window is narrow — the kills in one round are microseconds apart, against a PID space of 99999 on macOS and 4194304 on Linux — but the `README` states the guarantee without qualification, and buying microseconds of teardown time by weakening it is the wrong trade. Keeping both `snapshot().alive` and `isAlive` is therefore not two ways to ask one question: one asks what the table showed, the other asks what is true now, and only the second may decide a signal. + +**Making `exec` asynchronous instead of reducing the read count.** An async `execFile` stops the poll from blocking the loop but still forks N+1 processes per poll; on a busy machine that trades a stall for sustained fork pressure. It remains a worthwhile follow-up on top of the reduced count, not a substitute for it. + +## Consequences + +A readiness poll's process-table cost is now constant in descendant count. On macOS one poll performs one full table read plus the small `tpgid` read, which is the 0-descendant cost in the table above for every descendant count. + +Teardown keeps its previous per-signal cost: one narrow liveness read per target, which on macOS is one `ps` fork per member. That cost was never the measured problem — a terminal tears down once, while its readiness path polls up to 600 times — so the fix deliberately spends it to keep the fence reading current state. + +A snapshot is a point-in-time view, and the type's documentation says so. `waitForMembers` re-captures per iteration because observing change is its purpose, and no signal is ever decided from a captured view. + +Every `ProcessInspector` implementation and test fake carries the new shape, including the Windows inspector and the `dsh-terminal-bash` session fake. Test fakes that previously replaced `processTree`, `processSession`, or `isAlive` to stage a scan now replace the corresponding per-question read hook, which keeps their staging behavior and call-counting identical. + +The synchronous `execFileSync` boundary and the fixed 50 ms poll interval are unchanged; both remain open follow-ups for the same readiness path. diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md new file mode 100644 index 0000000000..9c5fe65bb8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md @@ -0,0 +1,71 @@ +# Agent Note: Process-table snapshots replace per-question inspector reads + +Status: implemented + +[English](2026-08-27-process-table-snapshots.md) | 中文 + +## Problem + +一次终端就绪轮询要向平台问三个问题:shell 的子进程树、它的 POSIX 会话成员、以及每个被跟踪的子进程是否仍在运行。当每个问题各自去读一次进程表时,这次轮询的代价就随着当前命令派生出的子进程数量增长。 + +在 macOS 上每一次读取都是一次 `/bin/ps -axo` fork,并解析整张表——在实测主机上 795 个进程需要 14.33 ms。`LocalTerminalHandle.inspectForeground()` 读一次树,然后按每个被跟踪的子进程各问一次存活,所以 N 个子进程的一次轮询要读 N+1 次表。`dsh-terminal-bash` 每 50 ms 轮询一次、最长 30 s,而 `ProcessInspectorInternals.exec` 是 `execFileSync`,因此每次轮询在其整个时长内阻塞事件循环。 + +用生产环境的 `MacProcessInspector` 驱动真实进程树实测: + +| 被跟踪的子进程数 | 一次轮询 | 占 50 ms 间隔的比例 | +|---|---|---| +| 0 | 18.0 ms | 36% | +| 1 | 33.8 ms | 68% | +| 2 | 49.1 ms | 98% | +| 5 | 87.1 ms | 174% | +| 10 | 178.4 ms | 357% | + +任何派生两个及以上子进程的命令——一条管道、`make`、`pnpm`、`git`——都会把宿主事件循环打满,直到它退出。 + +拆卸路径的结构相同。`signalProcess` 自己去问存活来给每个信号加 PID 复用围栏,因此向 N 个成员发信号要读 N 次表。 + +## Decision + +`ProcessInspector.snapshot()` 返回一个 `ProcessSnapshot`,即对进程表的一次观察,由它回答 `tree(rootPid)`、`session(sessionId)` 和 `alive(identity)`。它取代了那三个按问题划分的方法;检查器剩下的接口是 `foregroundPgid`、`isStdinWaiting`、`signalGroup` 和 `signalProcess`。 + +每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。`waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。 + +发信号不共用这份观察。`ProcessInspector.isAlive(identity)` 用各平台最窄的按标识来源回答当前状态——Linux 读一个 `/proc//stat`、macOS 读一次 `ps` 表、Windows 查一次进程句柄——`signalProcess` 在投递信号前就地取这道围栏。观察无法代替它:观察把原始的「PID 与起始时间」配对保留了下来,因此被复用的 PID 仍会与之匹配,并领走本该发给已退出进程的信号。逐目标读取围栏还让一次失败的读取只损失一个目标,而不是整轮拆卸的其余部分,这正是[同步退出清理约定](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)的要求。 + +`signalMembers` 与 `waitForMembers` 在一轮没有成员时直接返回、不做任何捕获,因此没有派生子进程的命令,其拆卸扫描不付表读取代价。 + +平台差异体现在快照如何构建,而不在它承诺什么: + +- **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。 +- **Linux** 遍历一次 `/proc`,携带每个条目的父进程、起始标识、会话与状态。`alive` 把 `Z`、`X`、`x` 状态视为静止,与按 pid 读 `stat` 的判定一致。 +- **Windows** 把 Toolhelp32 枚举惰性化到第一次 `tree` 提问。它没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列——所以只问存活的快照永不枚举。终端在 Windows 上的拆卸每 25 ms 轮询一次存活,否则每次都会遍历整张表再丢弃。 + +`PosixProcessSnapshot` 同时承载两种 POSIX 形态:当平台的表省略某字段时,该行的 `session` 与 `state` 为 `undefined`,这使得 macOS 的答案从共享实现中自然得出,而不必新增一个类。 + +## Testing + +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。同一文件还钉住:没有成员的一轮信号不做任何捕获;同步主机退出期间捕获失败时,PTY root 仍会被杀掉。 + +`process-inspector.spec.ts` 直接钉住围栏:一个先被观察为存活、随后从表中消失的标识不会收到信号。`windows-inspector.spec.ts` 钉住只回答存活的快照不执行 Toolhelp32 枚举。 + +## Alternatives considered + +**只加一个批量的 `aliveMembers(members)`,其余方法不动。** 这能合并按成员的读取,改动也小得多,但树的读取仍然独立,因此 macOS 上一次轮询仍要 fork 两次 `ps` 外加 `tpgid` 读取——10 个子进程时约 32 ms,仍占 50 ms 间隔的 64%。事件循环依旧大部分时间被阻塞,实测到的问题在修复之后依然存在。 + +**在 `MacProcessInspector` 内部用短 TTL 缓存 macOS 的表。** 这不需要改接口,但它让陈旧性不可见:调用方无法分辨一个存活答案来自此刻还是来自上一次轮询结束时,而基于陈旧行发出的信号正是 PID 复用围栏要防止的事情。隐式缓存也与仓库偏好显式默认与显式边界的立场冲突。 + +**用整轮共享的观察来给信号加围栏。** 这能去掉最后一处按成员的读取,也是本次最初实现的形态。评审否决了它:围栏的存在就是为了击败 PID 复用,而观察反过来击败了围栏——因为它把原始的「PID 与起始时间」配对一路带了下来。窗口确实很窄(一轮里各次 kill 相隔微秒级,而 macOS 的 PID 空间是 99999、Linux 是 4194304),但 `README` 是无条件地声明这条保证的,用削弱它来换取微秒级的拆卸时间是错误的取舍。因此同时保留 `snapshot().alive` 与 `isAlive` 并不是同一个问题的两种问法:前者问表当时显示了什么,后者问此刻什么为真,而只有后者可以决定一次信号。 + +**把 `exec` 改成异步,而不是减少读取次数。** 异步的 `execFile` 能让轮询不再阻塞事件循环,但每次轮询仍然 fork N+1 个进程;在繁忙的机器上这是把一次停顿换成了持续的 fork 压力。它在减少读取次数之上仍是值得做的后续项,而不是它的替代。 + +## Consequences + +一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。 + +拆卸保持原有的按次代价:每个目标一次窄的存活读取,在 macOS 上即每个成员一次 `ps` fork。这项代价从来不是实测到的问题——一个终端只拆卸一次,而它的就绪路径最多轮询 600 次——所以本次修复刻意付出它,以保证围栏读的是当前状态。 + +快照是一个时间点视图,该类型的文档也这样声明。`waitForMembers` 每轮重新捕获是因为观察变化正是它的用途;任何信号都不会从一份已捕获的视图上做决定。 + +每个 `ProcessInspector` 实现与测试替身都采用新形态,包括 Windows 检查器和 `dsh-terminal-bash` 的会话替身。此前通过替换 `processTree`、`processSession` 或 `isAlive` 来编排扫描的测试替身,现在替换对应的按问题读取钩子,其编排行为与调用计数保持不变。 + +同步的 `execFileSync` 边界与固定的 50 ms 轮询间隔未做改动;两者都仍是同一条就绪路径上待办的后续项。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index 525c192406..01d1644420 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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 .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: c7c076f34dcd4b905a6bb54411538d6cf61bc1d0 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 2dd866404a5a799fe33e8b9c70c17787dfed0c2a +2026-07-29-pnpm-setup-runner-isolation.md: d5cd02cceba920368f0dfe6535e4bd03ee075417 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 5266112224b940c06ea2567247532eb15ce7fce8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index c7c076f34d..d5cd02cceb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,9 +10,9 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory, and the run/attempt suffix also protects sequential jobs on the same runner from a stale locked `pnpm.exe`. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). -[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. +[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml`, `ci-master.yml`, and `build-exe-for-python-sdk.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 2dd866404a..5266112224 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录;run/attempt 后缀还能防止同一 runner 上顺序作业因残留的锁定 `pnpm.exe` 而失败。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 -[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 +[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml`、`ci-master.yml` 与 `build-exe-for-python-sdk.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md deleted file mode 100644 index b19d0842d8..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Code Mode collapses the executor, not just the wire - -Status: implemented - -English | [中文](2026-08-07-code-mode-executor-collapse.zh.md) - -## Problem - -`mode: 'code'` collapsed only the announcement surface, not the execution surface. `wireSchemas()` sent the model exactly one tool — `run_code` — but the executor resolved every call through `get()`, which returns the full visible map plus the reserved transport. A model that emitted a native tool name (`write`, `read`, `bash`, `subagent`, …) bypassed `run_code` entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing. - -The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor. - -## Decision - -`ToolRuntime` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. When `modeFor(scope)` resolves to `code`, a model-direct call (`nested = false`) may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error, whose message names the route back through `run_code` because the name IS declared to this model (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). The effective scope mode includes declarations inherited from an agent preset, so its wire schema and execution permissions remain aligned. A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. - -Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not. - -The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `code` returns `UNKNOWN_TOOL`, the same tool via an SDK sub-dispatch succeeds, and `native`/`both` direct calls plus `run_code` itself are unchanged. The base [Code Mode foundation](../feature/2026-06-15-code-mode.md) owns the transport design this note layers the execution boundary onto. - -## Alternatives considered - -### Filter `get()` / the registry view by mode - -The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor. - -### Filter at the agent-loop entry - -The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-encode mode semantics the registry already owns. - -### Reject via a shipped guard - -Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself. - -### Keep schema omission only (status quo) - -No provider guarantees interception of unadvertised names; the reported session proves it does not happen. - -## Consequences - -- `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract). -- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). -- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. -- Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-observation-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls. -- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of first-party per-tool guidance. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt. -- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md deleted file mode 100644 index 38bb319c8c..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Code Mode 塌缩执行器而非仅通告面 - -Status: implemented - -[English](2026-08-07-code-mode-executor-collapse.md) | 中文 - -## 问题 - -`mode: 'code'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 - -包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。 - -## 决策 - -`ToolRuntime` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。当 `modeFor(scope)` 解析为 `code` 时,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现,其消息会指出改走 `run_code` 的正确路径——因为这个名字对当前模型而言是**已声明过**的(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。有效的 scope 模式包括从 agent preset 继承的声明,因此其 wire schema 与执行权限保持一致。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 - -执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。 - -塌缩是安全相关的不变量,因此验收经执行器钉死:`code` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [Code Mode 基础](../feature/2026-06-15-code-mode.zh.md) 之上,传输设计由后者拥有。 - -## 备选方案 - -### 按模式过滤 `get()` / 注册表视图 - -视图被展示方、`tool-cordis` 检查与 SDK 绑定消费;塌缩视图会从程序表面隐藏仍必须绑定的工具,并改变所有消费者的公共解析契约,而不只是执行器。 - -### 在 agent-loop 入口过滤 - -loop 不是唯一的执行器调用方,且真正要紧的区分(模型直呼 vs 传输子调用)挂在执行输入上,不在 loop 边界。入口过滤还会重复编码注册表已经拥有的模式语义。 - -### 通过内置 guard 拒绝 - -guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装了正确的插件。模式决策归注册表所有,必须由它自己执行。 - -### 只保留 schema 省略(维持现状) - -没有提供方保证拦截未通告的名字;被报告的会话证明拦截不会发生。 - -## 后果 - -- `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 -- `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 -- 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 -- 原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)保留在系统提示词中,因为它们同时描述了通过生成 SDK 及原生函数调用可用的能力,其中若干段还承载着任何单个工具描述都装不下的跨工具路由策略(`read` 优先于 `bash cat`、默认 fs-observation-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 -- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 -- 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml index 4d6d2f9225..528aa1f781 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.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 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md -2026-07-26-code-dispatch-log-spill.md: a4b8deee86b1f6102e48e34079a93a74dfcfa288 -2026-07-26-code-dispatch-log-spill.zh.md: 5e53d761367ddce7747109e49c7facea9bcdae49 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md +2026-08-07-ptc-executor-collapse.md: e034d30ff54673ecef98faf5c632b92c2d39aa2f +2026-08-07-ptc-executor-collapse.zh.md: 03f93a323533b8507043c2fa8653b4c51c5fd56e diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md new file mode 100644 index 0000000000..e034d30ff5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md @@ -0,0 +1,46 @@ +# Agent Note: PTC mode collapses the executor, not just the wire + +Status: implemented + +English | [中文](2026-08-07-ptc-executor-collapse.zh.md) + +## Problem + +`mode: 'ptc'` collapsed only the announcement surface, not the execution surface. `wireSchemas()` sent the model exactly one tool — `run_code` — but the executor resolved every call through `get()`, which returns the full visible map plus the reserved transport. A model that emitted a native tool name (`write`, `read`, `bash`, `subagent`, …) bypassed `run_code` entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing. + +The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor. + +## Decision + +`ToolRuntime` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. When `modeFor(scope)` resolves to `ptc`, a model-direct call (`nested = false`) may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error, whose message names the route back through `run_code` because the name IS declared to this model (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). The effective scope mode includes declarations inherited from an agent preset, so its wire schema and execution permissions remain aligned. A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. + +Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not. + +The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `ptc` returns `UNKNOWN_TOOL`, the same tool via an SDK sub-dispatch succeeds, and `native`/`both` direct calls plus `run_code` itself are unchanged. The base [PTC mode foundation](../feature/2026-06-15-ptc.md) owns the transport design this note layers the execution boundary onto. + +## Alternatives considered + +### Filter `get()` / the registry view by mode + +The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor. + +### Filter at the agent-loop entry + +The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-enable PTC mode semantics the registry already owns. + +### Reject via a shipped guard + +Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself. + +### Keep schema omission only (status quo) + +No provider guarantees interception of unadvertised names; the reported session proves it does not happen. + +## Consequences + +- `mode: 'ptc'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract). +- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). +- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. +- Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-observation-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls. +- The prompt STATES the collapse, in the `tools:ptc-only` section ordered ahead of first-party per-tool guidance. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. The TypeScript SDK section repeats the distinction next to the generated declarations, labels them as program-only bindings, and states that only separately supplied tool schemas grant direct-call availability. Because the declaration list can otherwise be read as native tool availability, the section emits a complete `run_code` call around `tools.bash(...)` when the current `bash` parameter schema accepts the example arguments. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `ptc-turn`'s expected prompt. +- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md new file mode 100644 index 0000000000..03f93a3235 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md @@ -0,0 +1,46 @@ +# Agent Note: PTC mode 塌缩执行器而非仅通告面 + +Status: implemented + +[English](2026-08-07-ptc-executor-collapse.md) | 中文 + +## 问题 + +`mode: 'ptc'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 + +包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。 + +## 决策 + +`ToolRuntime` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。当 `modeFor(scope)` 解析为 `ptc` 时,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现,其消息会指出改走 `run_code` 的正确路径——因为这个名字对当前模型而言是**已声明过**的(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。有效的 scope 模式包括从 agent preset 继承的声明,因此其 wire schema 与执行权限保持一致。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 + +执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。 + +塌缩是安全相关的不变量,因此验收经执行器钉死:`ptc` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [PTC mode 基础](../feature/2026-06-15-ptc.zh.md) 之上,传输设计由后者拥有。 + +## 备选方案 + +### 按模式过滤 `get()` / 注册表视图 + +视图被展示方、`tool-cordis` 检查与 SDK 绑定消费;塌缩视图会从程序表面隐藏仍必须绑定的工具,并改变所有消费者的公共解析契约,而不只是执行器。 + +### 在 agent-loop 入口过滤 + +loop 不是唯一的执行器调用方,且真正要紧的区分(模型直呼 vs 传输子调用)挂在执行输入上,不在 loop 边界。入口过滤还会重复编码注册表已经拥有的模式语义。 + +### 通过内置 guard 拒绝 + +guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装了正确的插件。模式决策归注册表所有,必须由它自己执行。 + +### 只保留 schema 省略(维持现状) + +没有提供方保证拦截未通告的名字;被报告的会话证明拦截不会发生。 + +## 后果 + +- `mode: 'ptc'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 +- `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 +- 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 +- 原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)保留在系统提示词中,因为它们同时描述了通过生成 SDK 及原生函数调用可用的能力,其中若干段还承载着任何单个工具描述都装不下的跨工具路由策略(`read` 优先于 `bash cat`、默认 fs-observation-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 +- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:ptc-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。TypeScript SDK 段在生成声明旁再次区分两者,将其标为只能在程序内使用的绑定,并说明只有单独提供的工具 schema 才赋予直呼权限。声明列表可能被误读为原生工具可用性,因此当当前 `bash` 参数 schema 接受示例参数时,该段会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `ptc-turn` 共用期望提示词的原因。 +- 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml index cc3873f137..377d9ea1aa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.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 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md -2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d -2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 +2026-08-12-onboarding-reads-every-provider.md: 43895d6bc317f13ece91a48f9b44c0e8da705dc8 +2026-08-12-onboarding-reads-every-provider.zh.md: 67f12dd9e90435f61776d0eac048d588fcdd7252 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md index 1f247a6c93..43895d6bc3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -24,7 +24,7 @@ Each card kind now owns its own close handler. `closeSetup` records the provider ## Alternatives considered -- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Deriving readiness from the model catalog (`session/modelCatalog`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. - **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. - **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. - **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md index fc6e43195a..67f12dd9e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -24,7 +24,7 @@ Status: implemented ## Alternatives considered -- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **从模型目录(`session/modelCatalog`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 - **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 - **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 - **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index 92d2177999..dd1cbe2e66 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.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 .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: bf8d167d742001ce65b3e96713a9603adb19603e -2026-08-13-bounded-cold-blank-verification.zh.md: 1418301b8cb3c1452cbed2bdaf974949126079e6 +2026-08-13-bounded-cold-blank-verification.md: cd50d29f0b5d417077d5d848415607885c39474a +2026-08-13-bounded-cold-blank-verification.zh.md: 851b1fb35126a42623e251bf790dde2029189b36 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index bf8d167d74..cd50d29f0b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -12,7 +12,7 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess ## Decision -`dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. +`dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 1418301b8c..851b1fb351 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -12,7 +12,7 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N ## Decision -`dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 +`dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml index bcd28b5585..762dc3711f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.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 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md -2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43 -2026-08-18-tool-row-file-open-failure.zh.md: f09c41585a538b408f6258f64583f63c0fa57b0d +2026-08-18-tool-row-file-open-failure.md: c1887c834933199d5acf9035632cc83d470777d7 +2026-08-18-tool-row-file-open-failure.zh.md: 8a851b4b1bd40c66de92316967df612f828c8b38 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md index e36552395b..c1887c8349 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -6,7 +6,7 @@ English | [中文](2026-08-18-tool-row-file-open-failure.zh.md) ## Problem -Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. +Tool-row path clicks already call `session/openWorkspacePath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal. @@ -16,7 +16,7 @@ The inject returns the `workspaces.openPath` promise. The chat view wraps that o The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy. -The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. +The Host message is shown as thrown. The chat view's `openFile` adapter prefixes `path open failed: ` onto the Remote error; the dialog does not unwrap that prefix. ## Alternatives considered @@ -30,4 +30,4 @@ A silent Host refusal is no longer a success from the reader's seat. Headless or ## Testing -Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `session/openWorkspacePath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md index f09c41585a..8a851b4b1b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 +工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `session/openWorkspacePath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 [用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.zh.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。 @@ -16,7 +16,7 @@ inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器 对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。 -Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 +Host 消息按抛出内容展示。聊天视图的 `openFile` adapter 会在 Remote 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错 ## 测试 -包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `session/openWorkspacePath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.i18n.yaml new file mode 100644 index 0000000000..565b158423 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.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/bug-fix/2026-08-26-stable-turn-process-order.md +2026-08-26-stable-turn-process-order.md: 7122b901c8d24abc10c0bea51dcfc7a815b7aeeb +2026-08-26-stable-turn-process-order.zh.md: 574855fd4c9f923dfc3a2a80b815ffed3b2d7422 diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md new file mode 100644 index 0000000000..7122b901c8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md @@ -0,0 +1,29 @@ +# Agent Note: Stable Turn-process ordering + +Status: implemented + +English | [中文](2026-08-26-stable-turn-process-order.zh.md) + +## Problem + +Turn-process eligibility changes as Assistant output streams, becomes a final answer, or is invalidated by a Tool call, Retry, or later Step. Ordering existing Chat Nodes from that mutable range moved the initial System prompt and pre-User Context across the opening User, so one logical row appeared at different transcript positions during a Turn. + +## Decision + +Existing Chat Nodes keep one presentation order throughout a page lifetime. Their positions depend on durable anchors, node kinds, and the opening human input, never on the mutable process start or answer boundary. Dependency replay after loading older history retains an already projected System prompt's anchor. A newly projected process control may be inserted between existing rows, while completion, Retry, later Steps, pagination completion, and manual disclosure only change visibility or add new evidence. + +System prompt is independent of Turn Process: the initial prompt remains visible above the opening User and never receives process-member or process-hidden state. A later prompt first projected from a partial window retains that position when earlier request history loads. Context injection remains process content. When a Context or another potential process row has an event anchor before the opening User, Chat places it after that User from its first projection; once available, the process control occupies the stable position between the User and those rows. Without opening human input, the control stays before the earliest process candidate from its first appearance. + +The existing [Turn-process folding decision](../feature/2026-08-14-web-turn-process-folding.md) continues to own membership, completion, persistence, focus, and pagination behavior; this note supersedes only its earlier decision to fold System prompt and to defer pre-User process ordering until a mutable range included those rows. + +## Alternatives considered + +**Keep System prompt inside Process but preserve its original position.** Rejected because a disclosure below the opening User would control content above itself, and collapsing would remove the request-wide instruction that visually frames that User message. + +**Exempt only System prompt.** Rejected because pre-User Context could still move when answer qualification changed, preserving the same class of visual discontinuity. + +**Reparent process rows under the disclosure.** Rejected because moving keyed rows across React parents remounts stateful renderers. + +## Consequences + +The stable first-Turn presentation is `System prompt → User → Process → Context and other process rows → final Assistant`. Pre-User injected Context can therefore differ from raw event order, but it uses that semantic position from its first render. Tests cover the initial state, process appearance, completion collapse, manual expansion, and content-only answer-boundary changes. diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md new file mode 100644 index 0000000000..574855fd4c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 稳定的轮次过程排序 + +Status: implemented + +[English](2026-08-26-stable-turn-process-order.md) | 中文 + +## 问题 + +Assistant 输出流式生成、成为最终正文,或因工具调用、Retry、后续步骤而失去正文资格时,轮次过程范围会变化。若既有 Chat Node 的排序依赖这段可变范围,首轮系统提示词和位于 User 之前的上下文会跨过开场 User,导致同一个逻辑行在轮次期间出现在不同位置。 + +## 决策 + +既有 Chat Node 在同一页面生命周期内保持同一展示顺序。其位置只依赖持久锚点、节点种类和开场人工输入,不依赖可变的过程起点或正文边界。加载更早历史触发依赖重放时,已经投影出的系统提示词保留原有锚点。新投影出的过程控件可以插入既有行之间;完成状态、Retry、后续步骤、分页完成与手动展开只改变可见性或增加新证据。 + +系统提示词独立于轮次过程:初始提示词始终显示在开场 User 上方,并且不会获得过程成员或过程隐藏状态。后续提示词若首次从不完整历史窗口投影,加载更早的请求历史后仍保留该位置。上下文注入仍属于过程内容。若上下文或其它潜在过程行的事件锚点早于开场 User,Chat 从首次投影起就将其展示在该 User 之后;过程控件出现后占据 User 与这些过程行之间的稳定位置。没有开场人工输入时,控件从首次出现起就位于最早的过程候选之前。 + +现有的[轮次过程折叠决策](../feature/2026-08-14-web-turn-process-folding.zh.md)继续负责成员关系、完成状态、持久化、焦点与分页行为;本记录仅取代其中“折叠系统提示词”以及“等可变范围纳入行后才调整 User 前过程顺序”的旧决定。 + +## 曾考虑的替代方案 + +**让系统提示词继续属于 Process,但保留原位置。** 不采用:位于开场 User 下方的 disclosure 会控制自身上方的内容,而且收起后会隐藏用于界定该 User 请求的整段指令。 + +**只排除系统提示词。** 不采用:位于 User 之前的上下文仍会随正文资格变化而移动,保留了同类视觉跳动。 + +**把过程行重新挂接到 disclosure 下。** 不采用:跨 React 父节点移动 keyed 行会重挂载有状态 renderer。 + +## 后果 + +稳定的首轮展示顺序为「系统提示词 → User → Process → 上下文及其它过程行 → 最终 Assistant」。因此,位于 User 之前的注入上下文展示顺序可能不同于原始事件顺序,但从首次渲染起保持不变。测试覆盖初始状态、过程出现、完成后默认收起、手动展开与仅正文边界变化的场景。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml similarity index 63% rename from .agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml rename to .agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 13987435b3..ed42409d93 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.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 .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: dbf77557c9f1aa59deed443b74ca3ef83137d773 -2026-06-15-code-mode.zh.md: 091623adcf1b543b104a20482f7a0106a6437f1d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-ptc.md +2026-06-15-ptc.md: 5b3971c33df4be46c8a466e564ac86ba6454663a +2026-06-15-ptc.zh.md: fe44d8a0e97913998fc001c92f632f493888c720 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md similarity index 70% rename from .agents/notes/implemented/feature/2026-06-15-code-mode.md rename to .agents/notes/implemented/feature/2026-06-15-ptc.md index dbf77557c9..5b3971c33d 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -1,8 +1,8 @@ -# Agent Note: Code Mode — the model writes TypeScript against the tool registry +# Agent Note: PTC mode — the model writes TypeScript against the tool registry Status: implemented -English | [中文](2026-06-15-code-mode.zh.md) +English | [中文](2026-06-15-ptc.zh.md) ## Problem @@ -10,7 +10,7 @@ In the registry's native presentation, the agent loop advertises every visible c For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. -Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. +Cloudflare's [PTC mode](https://blog.cloudflare.com/ptc/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. Tool presentation belongs to the registry that owns tool visibility: implementing a second presentation as an after-the-fact waterfall transform would make correctness depend on listener order and fight [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md). The execution substrate is also part of the foundation rather than a placeholder: Node `worker_threads` provides a separate isolate, an empty environment, heap caps, and termination of a hot synchronous loop, while fitting the harness's existing trust model (§Trust posture). @@ -18,37 +18,37 @@ Tool presentation belongs to the registry that owns tool visibility: implementin Three decisions, each elaborated in its own section below: -1. **Code Mode is a first-class presentation mode of `ToolRuntime`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. +1. **PTC mode is a first-class presentation mode of `ToolRuntime`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'ptc'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the Service Definition package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); Consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another Service Provider package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker-thread`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. -This note owns Code Mode's presentation, composition, isolation, and settlement foundation. The later [typed tool-return Agent Note](2026-07-20-code-mode-typed-tool-returns.md) owns the generated output map, canonical binding values, `ToolCallError`, and the lossless outer-output boundary. +This note owns PTC mode's presentation, composition, isolation, and settlement foundation. The later [typed tool-return Agent Note](2026-07-20-ptc-typed-tool-returns.md) owns the generated output map, canonical binding values, `ToolCallError`, and the lossless outer-output boundary. ### The registry owns the mode -`ToolRuntime` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. +`ToolRuntime` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'ptc' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: ptc }`) — no code edit, per the no-hardcoded-tunables convention. -**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'code'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. +**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'ptc'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. -**Interaction with `toolOrder`:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. +**Interaction with `toolOrder`:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'ptc'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using PTC mode updates its order config or drops it. -**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. +**SDK prompt section.** In `'ptc'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. -**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. +**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. **Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. ### The run_code tool and the dispatch bridge -Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: +Under `'ptc'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove PTC mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/code-dispatch-start`/`tool/code-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/code-dispatch-start`/`tool/code-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. **Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision. -**Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler that replaced it. +**Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel Agent Note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduler that replaced it. **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). @@ -64,9 +64,9 @@ Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool en - `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole. - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them. -- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the first backend; a Python backend says `'python'` and pairs with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` accepts any `language` with a registered SDK renderer and `run_code` flavor (TypeScript and Python ship; see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)) and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). +- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the first backend; a Python backend says `'python'` and pairs with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` accepts any `language` with a registered SDK renderer and `run_code` flavor (TypeScript and Python ship; see the [language-dispatch note](2026-07-31-ptc-language-dispatch.md)) and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). -Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. +Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when PTC mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. ### The worker-thread runtime @@ -74,31 +74,31 @@ Requests contain every runtime input; implementations own validated timeout and 1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. -3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. Code Mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute. +3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. PTC mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute. 4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. 5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. `maxOutputBytes` bounds only the combined serialized outer logs, completion, or diagnostic; intermediate binding values have no byte cap. Expiry, cancellation, and completion terminate the worker, and heap exits or outer overflow are explicit failures. 6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../../docs/defensive-patterns.md). ### Trust posture -The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. `worker.terminate()` stops the thread but not OS processes it spawned. Code Mode uses the same `tools/pre-execute` policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend. +The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. `worker.terminate()` stops the thread but not OS processes it spawned. PTC mode uses the same `tools/pre-execute` policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend. ### What the model sees -The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Both flavors state the same contract in their own primitive: independent read-only calls MAY overlap under `Promise.all` (TypeScript) or `asyncio.gather` (Python), mutating calls run alone in submission order, and dependent work sequences with `await`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. +The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-ptc-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Both flavors state the same contract in their own primitive: independent read-only calls MAY overlap under `Promise.all` (TypeScript) or `asyncio.gather` (Python), mutating calls run alone in submission order, and dependent work sequences with `await`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. The transport's own `description` and both SDK instruction flavors open by naming `code` and `description` as the call's two required arguments. Prose that describes the call as passing a program leaves the second argument discoverable only through the parameter schema, and a model that emits `{code}` alone loses the whole written program to an `INVALID_ARGS` rejection. ## Consequences -Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol messages. Sub-dispatch starts in submission order under a bounded overlap pool, while per-call contexts retain their source, envelope, and metadata through the outer result. +Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol messages. Sub-dispatch starts in submission order under a bounded overlap pool, while per-call contexts retain their source, envelope, and metadata through the outer result. ## Testing - **Worker runtime:** Real-worker tests cover typed binding values and failures, every lossless JSON completion root, invalid and over-limit output, exact combined ledger boundaries, compute and wall budgets, hostile binding traffic, empty environment, and disposal to quiescence. A built-package test runs the worker entry under plain Node. - **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup. -- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a Code Mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. -- **Snapshot:** The `code-mode-turn`, `both-mode-turn`, and `code-mode-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. +- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. +- **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. ## Alternatives considered @@ -106,13 +106,13 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **`node:vm` as the reference runtime, with hardening deferred.** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm) and cannot interrupt a hot loop. A worker thread provides a separate isolate, empty environment, `resourceLimits`, and reliable `terminate()` at bash-equivalent trust, so the reference and production implementation are one package without an unsafe-acknowledgement ceremony. -**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls. +**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under PTC mode for residual native calls. -**Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. Code Mode's queue decision kept the two compatible, and both later shipped: the metadata as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch plus per-tool binding parallelism on the same classifier. +**Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. PTC mode's queue decision kept the two compatible, and both later shipped: the metadata as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch plus per-tool binding parallelism on the same classifier. -**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it. +**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'ptc'`) one line away without imposing it. -**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and its design depends on evidence about how models split usage under `'both'`. +**Per-tool visibility tiers (this tool native, that tool ptc-only).** Deferred: it needs per-tool metadata and a presentation split that `'native' | 'ptc' | 'both'` does not, and its design depends on evidence about how models split usage under `'both'`. **Sanitized identifier aliases in the SDK** (`my-tool` → `my_tool`, Cloudflare's approach). Rejected: quoted keys on a `declare const` make every name reachable with zero alias-collision logic; models handle `tools["my-tool"](…)` fine. @@ -126,10 +126,10 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the Agent Note makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning. -**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Package modules separate these responsibilities (`ts-types.ts` and `code-mode.ts` beside `schema.ts`, `json-schema.ts`, and `presentation.ts`), while `ctx.codeRuntime` owns all code-runtime-specific implementation. +**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Package modules separate these responsibilities (`ts-types.ts` and `ptc.ts` beside `schema.ts`, `json-schema.ts`, and `presentation.ts`), while `ctx.codeRuntime` owns all code-runtime-specific implementation. **Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary. -**Sub-dispatch overlap is bounded by tool safety claims, not by the caller.** A program's `Promise.all` or `asyncio.gather` buys wall-clock parallelism only across calls the tool itself classifies concurrency-safe; a run of exclusive calls still costs its round-trips in sequence, and models may over-expect. Both flavors' SDK instructions state the real contract. This note shipped the serialized placeholder that made the risk absolute; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler and its overlap cap. +**Sub-dispatch overlap is bounded by tool safety claims, not by the caller.** A program's `Promise.all` or `asyncio.gather` buys wall-clock parallelism only across calls the tool itself classifies concurrency-safe; a run of exclusive calls still costs its round-trips in sequence, and models may over-expect. Both flavors' SDK instructions state the real contract. This note shipped the serialized placeholder that made the risk absolute; the [live-parallel Agent Note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduler and its overlap cap. **Budget metering reads the event loop, not a flag.** Busy-time polling (`eventLoopUtilization()`) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at `computeMs`; idle-on-slow-binding survives to `maxWallMs`), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass. `maxWallMs` is config, and it reaches `setTimeout`, which clamps a delay above `MAX_TIMER_DELAY_MS` (2^31-1 ms) to 1 ms; a positivity check alone therefore accepts a 25-day ceiling that expires on the first tick and times out every run. The worker runtime range-checks the field at load for that reason. `computeMs` needs no upper bound because it is compared against measured utilization instead of being handed to a timer. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md similarity index 70% rename from .agents/notes/implemented/feature/2026-06-15-code-mode.zh.md rename to .agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 091623adcf..fe44d8a0e9 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -1,8 +1,8 @@ -# Agent Note: Code Mode——模型针对工具注册表编写 TypeScript +# Agent Note: PTC mode——模型针对工具注册表编写 TypeScript Status: implemented -[English](2026-06-15-code-mode.md) | 中文 +[English](2026-06-15-ptc.md) | 中文 ## 问题 @@ -10,7 +10,7 @@ Status: implemented 对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 -Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 +Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。 @@ -18,37 +18,37 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 三项决策,各自在下方独立小节中展开: -1. **Code Mode 是 `ToolRuntime`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 +1. **PTC mode 是 `ToolRuntime`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'ptc'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含 Service Definition 包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.zh.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个 Service Provider 包,而非重新设计。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker-thread`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 -本说明负责定义 Code Mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-code-mode-typed-tool-returns.zh.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。 +本说明负责定义 PTC mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-ptc-typed-tool-returns.zh.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。 ### 注册表拥有模式 -`ToolRuntime` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: code }`),无需改代码,遵循 no-hardcoded-tunables 约定。 +`ToolRuntime` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'ptc' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: ptc }`),无需改代码,遵循 no-hardcoded-tunables 约定。 -**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。 +**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'ptc'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。 -**与 `toolOrder` 的交互:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 +**与 `toolOrder` 的交互:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'ptc'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 PTC mode 的部署需要更新其 order 配置或移除它。 -**SDK 提示词段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-code-mode-language-dispatch.zh.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 +**SDK 提示词段。** 在 `'ptc'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 -**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 +**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 **代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。 ### run_code 工具与分发桥 -在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: +在 `'ptc'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 PTC mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 **子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 -**并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 约定相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 负责。 +**并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 约定相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责。 **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.zh.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 @@ -64,9 +64,9 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 - `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与返回值可以完整跨越实现的序列化边界。 - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时,执行 promise 仍会 fulfill,并通过 `error` 字段返回失败结果。只有调用方/seam 误用(例如重复的绑定命名空间)时,`run()` 才会 reject;消费方仍在自己的错误边界处理不合规后端的拒绝。 - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.zh.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。 -- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——首个后端为 `'typescript'`;Python 后端声明 `'python'`,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 接受任何注册了 SDK 渲染器与 `run_code` flavor 的 `language`(TypeScript 与 Python 已交付;见[语言分发 note](2026-07-31-code-mode-language-dispatch.zh.md)),否则组装会显式失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 +- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——首个后端为 `'typescript'`;Python 后端声明 `'python'`,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 接受任何注册了 SDK 渲染器与 `run_code` flavor 的 `language`(TypeScript 与 Python 已交付;见[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md)),否则组装会显式失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 -请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会显式失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 +请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 PTC mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会显式失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 ### worker-thread 运行时 @@ -74,31 +74,31 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且会保留源码位置,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。 2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化,不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。 -3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用。Code Mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。 +3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用。PTC mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。 4. **通过消息端口桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。 5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。`maxOutputBytes` 只约束序列化后的外层日志、完成值或诊断的组合;中间绑定值没有字节数上限。到期、取消和完成都终止 worker,堆退出或外层溢出会作为显式失败报告。 6. **dispose(资源释放)至完全停稳**:服务自身的 dispose 终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.zh.md)。 ### 信任姿态 -worker 运行时只能约束程序的运行,而不构成安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 +worker 运行时只能约束程序的运行,而不构成安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。PTC mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 ### 模型看到的内容 -SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.zh.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一约定:相互独立的只读调用可以(MAY)在 `Promise.all`(TypeScript)或 `asyncio.gather`(Python)下重叠,有副作用的调用按提交顺序单独运行,有依赖的工作用 `await` 排序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 +SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一约定:相互独立的只读调用可以(MAY)在 `Promise.all`(TypeScript)或 `asyncio.gather`(Python)下重叠,有副作用的调用按提交顺序单独运行,有依赖的工作用 `await` 排序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 传输自身的 `description` 与两种 flavor 的 SDK 说明都以点名 `code` 和 `description` 这两个必填参数开头。把该调用描述成「传入一个程序」的散文会让第二个参数只能从参数 schema 中发现,而只发出 `{code}` 的模型会因 `INVALID_ARGS` 被拒,连同已写好的整个程序一起丢失。 ## 后果 -切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议消息的完整性。子分发在有界的重叠池下按提交顺序启动,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 +切换到 `'ptc'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议消息的完整性。子分发在有界的重叠池下按提交顺序启动,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 ## 测试 - **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至完全停稳。一个构建后包测试在纯 Node 下运行 worker 入口。 - **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层阻止抑制以及 HMR(热模块替换)清理。 -- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 -- **快照:** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 +- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 +- **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 ## 曾考虑的替代方案 @@ -106,13 +106,13 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 -**在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 +**在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 PTC mode 下为残余的原生调用分层。 -**循环中的并行原生分发。** 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的队列决策保持了两者兼容,两者后来都已交付:元数据即 `isConcurrencySafe`(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.zh.md)),原生 rolling-pool 分发加每工具绑定并行化则基于同一个分类器。 +**循环中的并行原生分发。** 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。PTC mode 的队列决策保持了两者兼容,两者后来都已交付:元数据即 `isConcurrencySafe`(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.zh.md)),原生 rolling-pool 分发加每工具绑定并行化则基于同一个分类器。 -**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。 +**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'ptc'`)只需一行配置即可启用,而不强加于人。 -**每工具可见性分层(此工具 native,彼工具 code-only)。** 推迟:它需要每工具元数据和 `'native' | 'code' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。 +**每工具可见性分层(此工具 native,彼工具 ptc-only)。** 推迟:它需要每工具元数据和 `'native' | 'ptc' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。 **SDK 中的清洁化标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`。 @@ -126,10 +126,10 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **SDK 的提示词成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 按部署配置;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 -**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内模块把这些职责分开(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`、`json-schema.ts`、`presentation.ts` 并列),所有 code-runtime 专用实现都由 `ctx.codeRuntime` 提供。 +**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内模块把这些职责分开(`ts-types.ts`、`ptc.ts` 与 `schema.ts`、`json-schema.ts`、`presentation.ts` 并列),所有 code-runtime 专用实现都由 `ctx.codeRuntime` 提供。 **大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 -**子分发的重叠由工具自身的安全声明限定,而非由调用方决定。** 程序里的 `Promise.all` 或 `asyncio.gather` 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实约定。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 负责。 +**子分发的重叠由工具自身的安全声明限定,而非由调用方决定。** 程序里的 `Promise.all` 或 `asyncio.gather` 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实约定。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责。 **预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)是抵御恶意程序的关键。两种情况均有单元测试(带 pending 诱饵分发的热循环会在耗尽 `computeMs` 预算时终止;等待慢速绑定的空闲程序则会持续运行至 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项,且会传入 `setTimeout`,后者会把超过 `MAX_TIMER_DELAY_MS`(2^31-1 ms)的延迟夹到 1 ms;因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期,使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index 1242784a2c..75cc5ae485 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.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 .agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md -2026-06-17-filesystem-tool-schemas.md: 7680b1200314b5cfe89f8243e2690c748d8c00fe -2026-06-17-filesystem-tool-schemas.zh.md: d75c3f83d7be6dde2c9b70a40411433c581ce830 +2026-06-17-filesystem-tool-schemas.md: 889129682f876a04c8ff7be0ce9118cc7b721e55 +2026-06-17-filesystem-tool-schemas.zh.md: 704bd4692bec760fa9ec8d01489ea48d67363d0d diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 7680b12003..889129682f 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -90,7 +90,7 @@ The following are deliberately out of scope for the first filesystem schema pass - Directory listing, glob, grep, and search tools. - Binary-safe read/write operations. - PDF/image/multimodal `read`. -- Code Mode projection values for filesystem tools. +- PTC mode projection values for filesystem tools. - A canonical edit diff format. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index d75c3f83d7..704bd4692b 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -90,7 +90,7 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 - 目录列表、glob、grep 和搜索工具。 - 二进制安全的读/写操作。 - PDF/图片/多模态 `read`。 -- 文件系统工具的 Code Mode 投影值。 +- 文件系统工具的 PTC mode 投影值。 - 规范的 edit diff 格式。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 01d5ed79ff..98ac192fd7 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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 .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: b9009bd482b23e77bf60a9f45b228258a8d22343 -2026-06-24-workspace-context.zh.md: 96df776d565f064382587c64adb54f4562c98668 +2026-06-24-workspace-context.md: b676ab146b1449d15cfd20ff667899745344c0bf +2026-06-24-workspace-context.zh.md: e75adf39f3000e36aa2360b0225828cb5262a88c diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index b9009bd482..b676ab146b 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -38,7 +38,7 @@ The baseline is a user-role `` with `Instructions from: ` ### Dynamic Discovery And Refresh -After a successful first-party `read`, `write`, or `edit` call, the immutable `tools/result` observer reconciles the touched descendant chain and every scope already known to the session, then queues an `Additional instructions from: ` system-reminder in the agent inbox for the next request. Under Code Mode, successful sub-dispatch touches bubble through opaque parent execution tokens until the top-level result settles. A touch produced inside an agent-loop step does not begin its asynchronous projection until the durable `step/end`; a direct tool execution outside an open step projects immediately. The two boundaries keep result and step adjacency deterministic without making the tool pipeline await filesystem discovery. +After a successful first-party `read`, `write`, or `edit` call, the immutable `tools/result` observer reconciles the touched descendant chain and every scope already known to the session, then queues an `Additional instructions from: ` system-reminder in the agent inbox for the next request. Under PTC mode, successful sub-dispatch touches bubble through opaque parent execution tokens until the top-level result settles. A touch produced inside an agent-loop step does not begin its asynchronous projection until the durable `step/end`; a direct tool execution outside an open step projects immediately. The two boundaries keep result and step adjacency deterministic without making the tool pipeline await filesystem discovery. A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 96df776d56..e75adf39f3 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -38,7 +38,7 @@ Status: implemented ### 动态发现与刷新 -第一方 `read`、`write` 或 `edit` 调用成功后,不可变的 `tools/result` 观察器会协调被触及的后代路径链,以及该会话已经知道的每个作用域,然后在 agent inbox 中排入一条 `Additional instructions from: ` system-reminder,供下一次请求使用。在 Code Mode 下,成功的子分派 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定。在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影;打开的步骤之外直接执行工具时,则立即投影。这两个边界在不让工具流水线等待文件系统发现的前提下,保证结果/步骤的相邻关系具有确定性。 +第一方 `read`、`write` 或 `edit` 调用成功后,不可变的 `tools/result` 观察器会协调被触及的后代路径链,以及该会话已经知道的每个作用域,然后在 agent inbox 中排入一条 `Additional instructions from: ` system-reminder,供下一次请求使用。在 PTC mode 下,成功的子分派 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定。在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影;打开的步骤之外直接执行工具时,则立即投影。这两个边界在不让工具流水线等待文件系统发现的前提下,保证结果/步骤的相邻关系具有确定性。 内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index be71df12c0..0f1df83a62 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.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 .agents/notes/implemented/feature/2026-06-30-hook-bridges.md -2026-06-30-hook-bridges.md: c78460808c029b432f2d556485b0f9069dae4c4a -2026-06-30-hook-bridges.zh.md: 5499d641cacd3af764d2be7c450f4fc92b15e078 +2026-06-30-hook-bridges.md: 30d481f8bda2c65433863e55a75c862cc42479f0 +2026-06-30-hook-bridges.zh.md: 5d994bb0634aeca774d2265e1eba8487e8011ce2 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index c78460808c..30d481f8bd 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -41,7 +41,7 @@ Every bridge `inject()` and additional-context input explicitly passes `{ kind: ### Adding context is not a veto — delegate, then prepend -A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `enter` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/pre-step` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to a downstream enter decision. The bridge preserves every downstream message, while a downstream pre-step rejection drops the whole claimed batch because no step opens. Post-tool decisions retain their independent ordered `additionalContexts` semantics, including Code Mode deferral through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still reject a prompt after a context-only hook and that retained prompt and post-tool contexts remain separate. +A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `enter` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/pre-step` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to a downstream enter decision. The bridge preserves every downstream message, while a downstream pre-step rejection drops the whole claimed batch because no step opens. Post-tool decisions retain their independent ordered `additionalContexts` semantics, including PTC mode deferral through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still reject a prompt after a context-only hook and that retained prompt and post-tool contexts remain separate. ### CLAUDE_PROJECT_DIR defaults to the session workspace diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 5499d641ca..5d994bb063 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -41,7 +41,7 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 ### 添加上下文不是否决——先 delegate,再 prepend -仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall(瀑布式事件)监听器中不调用 `next()` 就返回 `enter`,会短路其后的每个 `agent/pre-step` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游 enter 决策。桥接会保留所有下游消息;下游 pre-step reject 会丢弃整个已领取批次,因为步骤从未打开。工具后决策仍保留独立的有序 `additionalContexts` 语义,包括 Code Mode 通过外层 `run_code` 结果延迟上下文。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:仅上下文钩子之后,较晚的监听器仍能 reject 提示词,且保留的提示词和工具后上下文仍彼此分离。 +仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall(瀑布式事件)监听器中不调用 `next()` 就返回 `enter`,会短路其后的每个 `agent/pre-step` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游 enter 决策。桥接会保留所有下游消息;下游 pre-step reject 会丢弃整个已领取批次,因为步骤从未打开。工具后决策仍保留独立的有序 `additionalContexts` 语义,包括 PTC mode 通过外层 `run_code` 结果延迟上下文。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:仅上下文钩子之后,较晚的监听器仍能 reject 提示词,且保留的提示词和工具后上下文仍彼此分离。 ### CLAUDE_PROJECT_DIR 默认为会话工作区 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 5ec6161319..e32202b83d 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.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 .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: cde9cf1130d6b6b971ddf35c64e34ad6d9fb7dee -2026-07-07-mcp-client-plugin.zh.md: 0cf69a97bc714efcc4083fc2b37519dfc10cdf32 +2026-07-07-mcp-client-plugin.md: 59983abc6288534a82aef903a8c6bed93db24be6 +2026-07-07-mcp-client-plugin.zh.md: 791b6f9eb10bef6a481caa106a0bda31b803dc4c diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index cde9cf1130..59983abc62 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -141,10 +141,10 @@ Tools are never silently skipped; which tools are available never depends on plu A unified `execute` handler for all tools from one MCP server: 1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server. -2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/Code Mode value. `isError: true` throws before any image persistence so the registry owns the failure path. +2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/PTC mode value. `isError: true` throws before any image persistence so the registry owns the failure path. 3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio, embedded resources, malformed blocks, and unknown types become explicit diagnostics. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references. 4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state. -5. Code Mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case. +5. PTC mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case. 6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate. ### Subprocess environment (stdio transport) @@ -197,9 +197,9 @@ Rejected. Programmatic callers need protocol-complete MCP blocks and `structured Rejected. Core already owns the role-neutral content vocabulary, and a second service would duplicate its logging and ordering contracts. `output.render` is pure, synchronous, and replayable, so attachment I/O belongs in async execution with an exact finalization handoff. -### Let each image-returning tool special-case Code Mode parents +### Let each image-returning tool special-case PTC mode parents -Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic Code Mode bridge observes the final post-policy content and forwards image-bearing results uniformly. +Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic PTC mode bridge observes the final post-policy content and forwards image-bearing results uniformly. ## Testing @@ -207,7 +207,7 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex - **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, lossless canonical results, mixed rich ordering, atomic malformed batches, exact capability/store refusal, explicit non-image diagnostics, post-execute policy precedence, cancellation, and config schema validation. 100% per-file coverage gates the package. - **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, durable image save/read with base64 retained only in the canonical value, explicit refusal without an image route, duplicate-`serverName` rejection, and disposal. -- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the Code Mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot. +- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the PTC mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 0cf69a97bc..791b6f9eb1 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -141,10 +141,10 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器: 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 -2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/Code Mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。 +2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/PTC mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。 3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频、嵌入资源、格式错误的块和未知类型成为明确诊断。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。 4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。 -5. Code Mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。 +5. PTC mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。 6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。 ### 子进程环境(stdio 传输) @@ -197,9 +197,9 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 不予采用。核心已经拥有角色无关的内容词汇,第二套服务会重复其日志与顺序契约。`output.render` 必须纯净、同步且可回放,因此附件 I/O 属于异步执行,再经确切的最终化交接安装结果。 -### 让每个返回图片的工具分别特殊处理 Code Mode 父调用 +### 让每个返回图片的工具分别特殊处理 PTC mode 父调用 -不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 Code Mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。 +不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 PTC mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。 ## 测试 @@ -207,7 +207,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp - **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、无损规范结果、丰富内容混合顺序、格式错误批次原子性、确切能力/存储拒绝、明确的非图片诊断、post-execute 策略优先级、取消,以及配置 schema 校验。100% 逐文件覆盖率门禁约束该包。 - **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、持久图片保存/读取且 base64 只保留在规范值中、缺少图片路由时明确拒绝、重复 `serverName` 拒绝,以及 dispose。 -- **快照**:组装后的 ACP 示例负责传输可见的内联图片 transcript 与 Code Mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照。 +- **快照**:组装后的 ACP 示例负责传输可见的内联图片 transcript 与 PTC mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index c3d47a1d34..3c267eaf73 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.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 .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md -2026-07-10-parallel-tool-call-execution.md: 81690377e22966eb28ef850a41a4c05c6fa534f0 -2026-07-10-parallel-tool-call-execution.zh.md: 3cee5533b7686f461548b97880e594fffee234d2 +2026-07-10-parallel-tool-call-execution.md: 870ab3bd6f48e7e2aad63baafb55020fd4c6ccd5 +2026-07-10-parallel-tool-call-execution.zh.md: ccab1d9b912ce6332bb493859bcefdb4b173b0d3 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 81690377e2..870ab3bd6f 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -48,7 +48,7 @@ Each started call appends `tool/call` immediately before its pre-execute gate. C An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls. -Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. +PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. ## Safety contract @@ -60,7 +60,7 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../../docs/config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. +The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, PTC mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. @@ -68,7 +68,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. -Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. +Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index 3cee5533b7..ccab1d9b91 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -48,7 +48,7 @@ Status: implemented 如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。 -Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 +PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 ## 安全约定 @@ -60,7 +60,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c `maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.zh.md)。 -当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.zh.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 +当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.zh.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、PTC mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 @@ -68,7 +68,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c 单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 -快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 +快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 927c70ef33..c13694c791 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.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 .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md -2026-07-12-subagent-persona-tool-filter-and-depth.md: 4e4fa3c0039004c50f9c803a951253a7f603f909 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: e2fb1508d780d39f587d279ab097f86ed7602c80 +2026-07-12-subagent-persona-tool-filter-and-depth.md: 7ba9768df3679da6b07728cf64237c47d4c73b2f +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: d3a8240542d896a27e82b1be1b491c241003e27e diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 4e4fa3c003..7ba9768df3 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -34,7 +34,7 @@ This uses the normal system-prompt registration mechanism rather than a second p ### Tool filtering is one live global-view rule -The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and Code Mode SDK generation. Independently registered system-prompt sections are outside `ToolRuntime`, so filtering a tool does not remove that plugin's standalone guidance. +The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections are outside `ToolRuntime`, so filtering a tool does not remove that plugin's standalone guidance. Resolution follows these rules: @@ -85,7 +85,7 @@ A security design would need a separate authority representation, propagation ru **Snapshot allowed global tools at child creation.** A frozen allow-set makes future registration uniformly unavailable, but it changes hot-registration semantics and starts an authorization design. The implemented filter stays a live registry predicate and documents allow-versus-deny behavior directly. -**Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through Code Mode or a forged call. One resolver governs both presentation and execution instead. +**Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through PTC mode or a forged call. One resolver governs both presentation and execution instead. **Encode the depth cap as an automatic tool filter.** A creation-time filter snapshots a decision that may depend on runtime state, affects only one configured tool name, and does not protect direct service callers or alternate delegation tools. The provider instead enforces the absolute cap at every start. diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index e2fb1508d7..d3a8240542 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -36,7 +36,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ### 工具过滤是一条作用于实时全局视图的规则 -工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRuntime` 内,因此过滤一个工具不会移除该插件的独立指导文本。 +工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 PTC mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRuntime` 内,因此过滤一个工具不会移除该插件的独立指导文本。 解析遵循以下规则: @@ -87,7 +87,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma **在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册统一不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为活跃的注册表谓词,并直接记录 allow 与 deny 的行为。 -**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个提示词声称不存在的工具。改为由一个解析器同时管控呈现和执行。 +**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 PTC mode 或伪造调用执行一个提示词声称不存在的工具。改为由一个解析器同时管控呈现和执行。 **把深度上限编码为自动工具过滤器。** 创建时过滤器会快照一个可能依赖运行时状态的决策,只影响一个已配置工具名,且不保护直接服务调用方或替代委派工具。提供方改为在每次启动时强制绝对上限。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index cb77a58801..c7a1607d19 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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 .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 8e9604072464f2b5adbd3b2058c54fab732d06d6 -2026-07-20-dsh-cli-personal-config.zh.md: 4863b5dbe19c125ab0b9829ffbffb673ea8d5961 +2026-07-20-dsh-cli-personal-config.md: db1e5638f06b66f74d4761d1eab4e5248e6def0b +2026-07-20-dsh-cli-personal-config.zh.md: d796d9887421aa7c8e9f3e8d1de66c74e10c139d diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 8e96040724..db1e5638f0 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -42,7 +42,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. - An installed `dsh` command can run from any directory, while source users invoke `pnpm dsh` from the checkout; both can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit. The behavior was verified end to end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. -- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. +- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (PTC mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-home-paths` for `resolveDshHome`. - Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 4863b5dbe1..d796d98874 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -42,7 +42,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 - 已安装的 `dsh` 命令可从任意目录运行,源码用户则从 checkout 调用 `pnpm dsh`;两者都无需修改 checkout 即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项。该行为已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.zh.md#profiles)(打印这些补丁合成出的配置树)。 -- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 +- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(PTC mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-home-paths` 以获取 `resolveDshHome`。 - 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml new file mode 100644 index 0000000000..8ef13a4638 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.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-20-ptc-typed-tool-returns.md +2026-07-20-ptc-typed-tool-returns.md: a4651eef89d32cdc2330eb5a2562bd34d617a3df +2026-07-20-ptc-typed-tool-returns.zh.md: d8e68e273bd18fe60d8ba8e0bbf435dbb773a9c2 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md similarity index 76% rename from .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md rename to .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md index a8a251f5f0..a4651eef89 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md @@ -1,22 +1,22 @@ -# Agent Note: Typed tool returns in Code Mode +# Agent Note: Typed tool returns in PTC mode Status: implemented -English | [中文](2026-07-20-code-mode-typed-tool-returns.zh.md) +English | [中文](2026-07-20-ptc-typed-tool-returns.zh.md) ## Problem -Code Mode originally projected each nested tool result back from `ContentBlock[]` into one string. That preserved the human-readable Native presentation but erased the canonical result the tool had already produced: programs had to scrape job ids and dynamic mount ids from prose, structured search and workflow results lost their shape, and non-text blocks became placeholders. The generated SDK could describe arguments but could only promise `Promise` regardless of the tool's real output. +PTC mode originally projected each nested tool result back from `ContentBlock[]` into one string. That preserved the human-readable Native presentation but erased the canonical result the tool had already produced: programs had to scrape job ids and dynamic mount ids from prose, structured search and workflow results lost their shape, and non-text blocks became placeholders. The generated SDK could describe arguments but could only promise `Promise` regardless of the tool's real output. The runtime also treated binding values and the final program value as presentation data. Separate log and completion caps could replace an oversized or non-cloneable completion with inspected text even though intermediate values do not enter model context. That made programmatic composition lossy and confused the memory boundary with the prompt boundary. -The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) establishes one validated execution-time value and a separate Native renderer. Code Mode should consume that value directly, preserve it across the worker boundary, and bound only the final output the program deliberately returns to the model. +The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) establishes one validated execution-time value and a separate Native renderer. PTC mode should consume that value directly, preserve it across the worker boundary, and bound only the final output the program deliberately returns to the model. ## Decision -Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. The outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and model-facing spill pipeline; a successfully settled sub-call whose final Native content contains an image additionally defers that complete ordered content through the parent result as logged, source-attributed context. +PTC mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. The outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and model-facing spill pipeline; a successfully settled sub-call whose final Native content contains an image additionally defers that complete ordered content through the parent result as logged, source-attributed context. -This note owns the return and failure contract layered on the original [Code Mode foundation](2026-06-15-code-mode.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note. +This note owns the return and failure contract layered on the original [PTC mode foundation](2026-06-15-ptc.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note. ### Generated SDK @@ -51,7 +51,7 @@ declare const tools: { Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program. Image-bearing final content is not a second binding value: the bridge ferries it after the outer result so the next model request can see the durable image, while post-execute block/content replacement remains authoritative and text-only results are not duplicated. -Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime Service Definition treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. +PTC mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime Service Definition treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. At module initialization the worker captures its own realm's `Array.prototype` and `Object.prototype` identities, the native function-source intrinsic used only to recognize foreign-realm plain-container prototypes, and every structural and metering intrinsic used by the JSON boundary. Property writes use null-prototype descriptors, while private array and set operations invoke captured methods without consulting mutable global or prototype slots. Model code can therefore replace helpers such as `Object.keys`, `Array.isArray`, collection methods, string methods, or `Buffer.byteLength`, rewrite intrinsic-prototype constructor slots, or add descriptor-shaped fields to `Object.prototype` without changing validation, wire transport, or byte accounting. The foreign-realm native function-source check still rejects user-authored constructors that imitate `Object` or `Array`. The dependency-light runtime Service Definition names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful. @@ -79,7 +79,7 @@ The opaque `exec.parent` token marks nested calls. Presentation metadata and gen ## Testing -Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, exotic names, and assembled Code Mode image forwarding. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; generic image-bearing context deferral plus post-execute replacement/block precedence; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. +Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, exotic names, and assembled PTC mode image forwarding. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; generic image-bearing context deferral plus post-execute replacement/block precedence; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its job id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `job_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text. @@ -93,13 +93,13 @@ Keyless real-worker integration tests pin the two handle workflows that prose re **Silently inspect or truncate an oversized completion.** Rejected because changing a JSON value into a string is lossy and type-incorrect. The explicit `output-limit` failure lets the model choose a smaller result, while the retained logs and diagnostic can still use normal outer spill. -**Require each rich leaf tool to inspect `exec.parent` and defer itself.** Rejected because it couples leaf tools to Code Mode internals, duplicates policy handling, and misses future rich tools. The dispatch bridge owns generic forwarding from the already settled final result. +**Require each rich leaf tool to inspect `exec.parent` and defer itself.** Rejected because it couples leaf tools to PTC mode internals, duplicates policy handling, and misses future rich tools. The dispatch bridge owns generic forwarding from the already settled final result. **Expose Native rich content as part of every binding's canonical value.** Rejected because a canonical value is lossless JSON and tool-specific; attachment blocks are a model projection with durable lifecycle semantics. Keeping the value and projection separate preserves typed programs without dropping images from later model context. ## Consequences -Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and UI presentation, while Code Mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer. +Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and UI presentation, while PTC mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer. The worker performs bounded-depth flat-wire transport and lossless validation but does not make intermediate values cheap or durable. Outer overflow is an explicit failed run, and error handling remains intentionally human-guided rather than a versioned code union. @@ -110,7 +110,7 @@ The worker performs bounded-depth flat-wire transport and lossless validation bu - Intermediate canonical values are execution-local and unavailable to replay because durable events persist only presentation and bounded summaries. - Intermediate values have no byte cap and can exhaust process or worker memory through retention, flat-wire copies, or structured-clone cost. - The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap. -- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches Code Mode. +- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches PTC mode. - Unsupported MCP output schemas fall back to `JsonValue`; admitted MCP images use the generic deferred projection, while audio and embedded-resource payloads remain diagnostic-only. - There is one result card per outer `run_code`, never per nested call. - Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union. diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md similarity index 74% rename from .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md rename to .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md index 2589d63d3f..d8e68e273b 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md @@ -1,22 +1,22 @@ -# Agent Note: Code Mode 的类型化工具返回值 +# Agent Note: PTC mode 的类型化工具返回值 Status: implemented -[English](2026-07-20-code-mode-typed-tool-returns.md) | 中文 +[English](2026-07-20-ptc-typed-tool-returns.md) | 中文 ## 问题 -Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 呈现,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 job id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 +PTC mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 呈现,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 job id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 运行时还把绑定值和程序最终返回值当作展示数据。日志和完成值分别设置上限,导致过大或无法克隆的完成值可能被替换为检查后生成的文本,而中间值本来就不会进入模型上下文。这种设计使程序化组合产生信息损失,也混淆了内存边界与提示词边界。 -[规范工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。Code Mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。 +[规范工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。PTC mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。 ## 决策 -Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线;如果成功结算的子调用最终 Native 内容包含图片,其完整有序内容还会经父结果延后为写入日志且带来源归属的上下文。 +PTC mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线;如果成功结算的子调用最终 Native 内容包含图片,其完整有序内容还会经父结果延后为写入日志且带来源归属的上下文。 -本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.zh.md)之上的返回值与失败约定。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.zh.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。 +本文档定义叠加在原始 [PTC mode 基础](2026-06-15-ptc.zh.md)之上的返回值与失败约定。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.zh.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。 ### 生成的 SDK @@ -51,7 +51,7 @@ declare const tools: { 分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。含图片的最终内容不是第二份绑定值:桥接层会在外层结果之后转运它,使下一次模型请求可以看到持久图片;post-execute 阻止/内容替换仍具有权威性,纯文本结果不会重复。 -Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 Service Definition 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把约定承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常约定,而不是供程序分类的失败联合。 +PTC mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 Service Definition 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把约定承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常约定,而不是供程序分类的失败联合。 绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。模块初始化时,worker 会捕获自身 JavaScript 运行域中 `Array.prototype` 和 `Object.prototype` 的引用、仅用于识别其他运行域普通容器原型、可获取原生函数源码的内建函数,以及 JSON 边界用于结构处理和计量的全部内建方法。属性写入使用原型为 null 的属性描述符;内部的数组与集合操作直接调用捕获的方法,不会访问可变的全局或原型槽位。因此,即使模型代码替换 `Object.keys`、`Array.isArray`、集合方法、字符串方法或 `Buffer.byteLength` 等辅助方法,重写内建原型的构造函数槽位,或向 `Object.prototype` 添加形如属性描述符的字段,也不会改变校验、协议传输或字节计量。面向其他运行域的原生函数源码检查仍会拒绝由用户编写、冒充 `Object` 或 `Array` 的构造函数。为保持依赖轻量,运行时 Service Definition 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文 spill 或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 @@ -79,7 +79,7 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper ## 测试 -编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围、特殊名称,以及组装后的 Code Mode 图片转发。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;通用含图片上下文延后以及 post-execute 替换/阻止优先级;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 +编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围、特殊名称,以及组装后的 PTC mode 图片转发。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;通用含图片上下文延后以及 post-execute 替换/阻止优先级;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 job id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且由 `job_kill` 负责取消。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。 @@ -93,13 +93,13 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper **静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层 spill 机制。 -**要求每个丰富叶子工具检查 `exec.parent` 并自行延后。** 不予采用,因为这会把叶子工具与 Code Mode 内部机制耦合、重复策略处理,并遗漏未来丰富工具。分发桥接层负责从已经结算的最终结果通用转发。 +**要求每个丰富叶子工具检查 `exec.parent` 并自行延后。** 不予采用,因为这会把叶子工具与 PTC mode 内部机制耦合、重复策略处理,并遗漏未来丰富工具。分发桥接层负责从已经结算的最终结果通用转发。 **把 Native 丰富内容暴露为每个绑定规范值的一部分。** 不予采用,因为规范值是无损 JSON 且由工具定义;附件块是具有持久生命周期语义的模型投影。保持值与投影分离,既能保留类型化程序,也不会从后续模型上下文中丢弃图片。 ## 后果 -Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 +PTC mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,PTC mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损校验,但不会降低中间值的开销,也不会使其具备持久性。外层输出溢出会显式导致运行失败,错误处理则有意由人类引导,而不是依赖带版本的错误代码联合。 @@ -110,7 +110,7 @@ worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损 - 中间规范值仅存在于执行期间,无法用于回放,因为持久事件只存储展示和有界摘要。 - 中间值没有字节上限,可能因值的保留、扁平协议格式副本或结构化克隆开销而耗尽进程或 worker 内存。 - 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;spill 无法恢复超出该上限后被拒绝的字节。 -- 提供方或执行器的采集上限可能在规范值到达 Code Mode 前就已丢弃部分源数据。 +- 提供方或执行器的采集上限可能在规范值到达 PTC mode 前就已丢弃部分源数据。 - 不支持的 MCP 输出 schema 会回退为 `JsonValue`;已准入的 MCP 图片使用通用延后投影,而音频和嵌入资源载荷仍只提供诊断。 - 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。 -- Code Mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。 +- PTC mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 951eb4a113..bbdf2836d7 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1dc1ffc3e3c0fdb3b7b084dff91a51209ac80458 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e1163b43c5c2c8ff9577468a0069fa6956f9fab4 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 135939b536e39db10fe1011501b7a7ce2b0956b2 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 3207e1f6a7fc5db65f4f4c7a51c2ae765f634cb7 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 1dc1ffc3e3..135939b536 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -10,7 +10,7 @@ Before this change, the Web composer accepted only text: `InputBar` received a s This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. -The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. +The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [archived GUI layering and RPC protocol decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. @@ -128,7 +128,7 @@ Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. -Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; Code Mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. +Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; PTC mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route uses the same deterministic request versions as ordinary turns. A text-only route receives the same deterministic attachment placeholders as any other LLM request. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. @@ -159,13 +159,13 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | | `packages/acp/acp` | Conditional native image capability, atomic inline-image admission, and verified assistant-image delivery. | | `packages/mcp/mcp-client` | Lossless canonical MCP results plus capability-gated durable image projection and explicit diagnostics for unsupported rich blocks. | -| `packages/core/tools` | Generic Code Mode forwarding of settled image-bearing sub-results after the outer result. | +| `packages/core/tools` | Generic PTC mode forwarding of settled image-bearing sub-results after the outer result. | The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. ### Implementation -The implemented capability includes shared prepare-once batch admission, provider-independent masters, deterministic request versions, DeepSeek Files reuse, stable crop handles, role-neutral image blocks, Pi-AI and DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image support, lossless MCP results with durable image projection, Code Mode rich-result forwarding, bounded Web requests, draft and historical image UI, compaction handling, and keyless assembled coverage. +The implemented capability includes shared prepare-once batch admission, provider-independent masters, deterministic request versions, DeepSeek Files reuse, stable crop handles, role-neutral image blocks, Pi-AI and DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image support, lossless MCP results with durable image projection, PTC mode rich-result forwarding, bounded Web requests, draft and historical image UI, compaction handling, and keyless assembled coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -201,11 +201,11 @@ Rejected because the core already has the role-neutral `ContentBlock` vocabulary ### Normalize MCP results into core content as the canonical tool value -Rejected because Code Mode and programmatic callers need the complete MCP JSON blocks and optional `structuredContent`; replacing that value with a Native projection would make the bridge lossy. MCP retains the protocol value and prepares a separate model projection, with final post-execute policy remaining authoritative. +Rejected because PTC mode and programmatic callers need the complete MCP JSON blocks and optional `structuredContent`; replacing that value with a Native projection would make the bridge lossy. MCP retains the protocol value and prepares a separate model projection, with final post-execute policy remaining authoritative. ### Perform attachment reads and writes inside synchronous output renderers -Rejected because tool renderers are pure, synchronous, and replayable. MCP prepares image projection during async execution and installs it only at the registry's finalization boundary; ACP performs async admission and output conversion in its transport lifecycle. Code Mode forwarding observes the already settled final content instead of giving individual image tools private parent-token behavior. +Rejected because tool renderers are pure, synchronous, and replayable. MCP prepares image projection during async execution and installs it only at the registry's finalization boundary; ACP performs async admission and output conversion in its transport lifecycle. PTC mode forwarding observes the already settled final content instead of giving individual image tools private parent-token behavior. ## Testing @@ -213,7 +213,7 @@ Rejected because tool renderers are pure, synchronous, and replayable. MCP prepa - Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection ordering, text-only queue edits, and text-only request projection. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.expected.e2e.ts`, `pnpm run test:web`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - Adapter and compaction tests cover deterministic Pi-AI request versions, DeepSeek Files upload and reuse, stale-id recovery, text-only projection, recursively nested tool-result images, shared summary request versions, and explicit image-output rejection. -- Attachment, MCP, ACP, and Code Mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. +- Attachment, MCP, ACP, and PTC mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. - Credentialed real-API tests cover the configured Anthropic route and the built-in `deepseek-official` Files path. The DeepSeek test does not use a custom provider entry. - The current production adapter set has no certified image-output route; output-provider certification remains outside version one. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index e1163b43c5..3207e1f6a7 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -10,7 +10,7 @@ Status: implemented 这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 -[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 +[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[已归档的 GUI 分层与 RPC 协议决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 @@ -128,7 +128,7 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 -提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;Code Mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 +提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;PTC mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 压缩会把选定的会话前缀和其中的图片引用回放到已配置的摘要生成路径。支持视觉的路径使用与普通轮次相同的确定性请求版本。纯文本路径接收与其他 LLM 请求相同的确定性附件占位符。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 @@ -159,13 +159,13 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, | `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | | `packages/acp/acp` | 条件式原生图片能力、原子内联图片准入,以及经过校验的助手图片交付。 | | `packages/mcp/mcp-client` | 无损规范 MCP 结果、经能力门禁的持久图片投影,以及针对不受支持丰富块的明确诊断。 | -| `packages/core/tools` | 在外层结果之后通用转发已经结算且含图片的 Code Mode 子结果。 | +| `packages/core/tools` | 在外层结果之后通用转发已经结算且含图片的 PTC mode 子结果。 | 附件包构成一个能力 seam 的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 ### 实现 -已实现能力包括只准备一次的共享批量准入、与提供方无关的主版本、确定性请求版本、DeepSeek Files 复用、稳定裁剪句柄、角色无关图片块、Pi-AI 和 DeepSeek 输入转换、Web/ACP/MCP 持久化顺序、Web 上传与读取协议、条件式 ACP 图片支持、带持久图片投影的无损 MCP 结果、Code Mode 丰富结果转发、有界 Web 请求、草稿与历史图片 UI、压缩处理,以及组装后的无密钥覆盖。 +已实现能力包括只准备一次的共享批量准入、与提供方无关的主版本、确定性请求版本、DeepSeek Files 复用、稳定裁剪句柄、角色无关图片块、Pi-AI 和 DeepSeek 输入转换、Web/ACP/MCP 持久化顺序、Web 上传与读取协议、条件式 ACP 图片支持、带持久图片投影的无损 MCP 结果、PTC mode 丰富结果转发、有界 Web 请求、草稿与历史图片 UI、压缩处理,以及组装后的无密钥覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -201,11 +201,11 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ### 把 MCP 结果规范化为核心内容,并将其作为规范工具值 -不予采用,因为 Code Mode 和程序化调用方需要完整 MCP JSON 块及可选 `structuredContent`;用 Native 投影替换该值会让桥接有损。MCP 保留协议值,并另行准备模型投影;最终 post-execute 策略仍具有权威性。 +不予采用,因为 PTC mode 和程序化调用方需要完整 MCP JSON 块及可选 `structuredContent`;用 Native 投影替换该值会让桥接有损。MCP 保留协议值,并另行准备模型投影;最终 post-execute 策略仍具有权威性。 ### 在同步输出渲染器中执行附件读写 -不予采用,因为工具渲染器必须纯净、同步且可回放。MCP 在异步执行期间准备图片投影,只在注册表最终化边界安装;ACP 在自己的传输生命周期中执行异步准入和输出转换。Code Mode 转发观察已经结算的最终内容,而不是让各图片工具各自处理私有父 token 行为。 +不予采用,因为工具渲染器必须纯净、同步且可回放。MCP 在异步执行期间准备图片投影,只在注册表最终化边界安装;ACP 在自己的传输生命周期中执行异步准入和输出转换。PTC mode 转发观察已经结算的最终内容,而不是让各图片工具各自处理私有父 token 行为。 ## 测试 @@ -213,7 +213,7 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 - 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的排序、仅文本的队列编辑,以及纯文本请求投影。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.expected.e2e.ts`,`pnpm run test:web`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖确定性 Pi-AI 请求版本、DeepSeek Files 上传与复用、陈旧 ID 恢复、纯文本投影、递归嵌套在工具结果中的图片、共享摘要请求版本,以及明确拒绝图片输出。 -- 附件、MCP、ACP 与 Code Mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 +- 附件、MCP、ACP 与 PTC mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 - 需要凭据的实际 API 测试会覆盖配置的 Anthropic 路由和内置 `deepseek-official` Files 路径。DeepSeek 测试不使用自定义提供方条目。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md deleted file mode 100644 index a4b8deee86..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Spilling the durable copy of Code Mode sub-dispatch results - -Status: implemented - -English | [中文](2026-07-26-code-dispatch-log-spill.zh.md) - -> Scope: limiting the `tool/code-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) defines the event pair that this listener processes. - -## Problem - -After full-content dispatch logging was added, a `run_code` program that reads a large file wrote the complete rendered text into the session log without a limit or spill policy, while native results were limited to `maxInlineBytes` before logging. This treated the most likely large results differently: sub-calls are intended for bulk data work, and each affected turn added megabytes to the JSONL. - -## Decision - -**A `tools/code-dispatch-log` waterfall on the registry, with spill policy as its first listener.** - -- **Extension point**: `tools/code-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/code-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `CodeDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. -- **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. -- **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. - -## Alternatives considered - -**Apply a plain byte limit inside the bridge without spill storage.** Rejected: truncation without a locator loses data that replay or UIs may need and restores the less informative "truncated summary" rendering that earlier changes removed. - -**Spill inside the bridge directly by calling `ctx.spillStore` from `code-mode.ts`.** Rejected: the registry would require the spill capability. The waterfall keeps this policy with the other spill decisions and allows compositions to omit it; omitting `maxInlineBytes` still makes the listener a no-op. - -**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute can change the program-facing result, so nested calls deliberately skip it and programs receive complete data. The durable copy needs a separate listener that runs after the program has its value. - -## Consequences - -Code Mode dispatch entries in the session log now have the configured byte limit, and the README's Known Limitations entry about unlimited dispatch logging now points here. Old logs with oversized dispatch content still replay because the event fields are unchanged; only future appends contain less text. The web UI renders spilled sub-call output as preview and locator text through the same path as native results, with no special case. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md deleted file mode 100644 index 5e53d76136..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 将 Code Mode 子分发结果的持久化副本纳入 spill 机制 - -Status: implemented - -[English](2026-07-26-code-dispatch-log-spill.md) | 中文 - -> 范围:用既有的 spill 实现限制 `tool/code-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.zh.md) 有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 定义了该监听器处理的事件对。 - -## 问题 - -加入完整内容的分发日志后,读取大文件的 `run_code` 程序会把完整的渲染文本写进会话日志,既没有上限,也不经过 spill 策略;原生结果则会在记录之前限制在 `maxInlineBytes` 以内。两类结果受到不同处理,而为批量数据工作设计的子调用最可能产生巨大结果;每个受影响的轮次都会让 JSONL 增长数 MB。 - -## 决策 - -**在注册表上增设 `tools/code-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** - -- **扩展点**:`tools/code-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/code-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 以能力闭包形式接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`CodeDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会等待开放轮次内的全部任务完成。 -- **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过被 spill 的原生结果所用的同一路径读取全文,因此两类结果会渲染出相同的信息。 -- **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 - -## 曾考虑的替代方案 - -**在桥接层内部使用普通字节数上限,不存入 spill。** 否决:没有定位符的截断会丢失回放或 UI 可能需要的数据,还会恢复之前更改已经移除的、信息较少的「截断摘要」渲染。 - -**直接在桥接层内做 spill,即从 `code-mode.ts` 调用 `ctx.spillStore`。** 否决:注册表会要求提供 spill 能力。waterfall 把该策略与其他 spill 决策放在一起,并允许组合不加载它;省略 `maxInlineBytes` 时,该监听器仍不执行任何操作。 - -**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 可以修改面向程序的结果,因此嵌套调用有意跳过它,让程序取得完整数据。持久化副本需要一个单独的监听器,在程序取得其值之后运行。 - -## 后果 - -会话日志中的 Code Mode 分发条目现在遵守已配置的字节数上限,README 中关于分发日志不设上限的「已知限制」条目现在指向本篇。携带超大分发内容的旧日志仍可回放,因为事件字段没有变化;只有今后的追加包含更少文本。Web UI 经由与原生结果相同的路径,把被 spill 的子调用输出渲染为预览和定位符文本,不需要特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml deleted file mode 100644 index bc71339309..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-26-code-mode-chat-subcall-rows.md -2026-07-26-code-mode-chat-subcall-rows.md: 4bf608c36de78818b3ab40c6798ee6eafabe99a1 -2026-07-26-code-mode-chat-subcall-rows.zh.md: 6e11f65313fb35dc202cb2cd230884b8862d9f13 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml new file mode 100644 index 0000000000..7d73b82e39 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.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-26-ptc-chat-subcall-rows.md +2026-07-26-ptc-chat-subcall-rows.md: 5ddbd56f75acd4c0d6de70708e5c8f810e797e83 +2026-07-26-ptc-chat-subcall-rows.zh.md: 499742d488860db0d987387cbd67be645922aa7e diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md similarity index 68% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md index 4bf608c36d..5ddbd56f75 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md @@ -1,14 +1,14 @@ -# Agent Note: Code Mode chat rendering — sub-calls as native rows under the parent +# Agent Note: PTC mode chat rendering — sub-calls as native rows under the parent Status: implemented -English | [中文](2026-07-26-code-mode-chat-subcall-rows.zh.md) +English | [中文](2026-07-26-ptc-chat-subcall-rows.zh.md) -> Scope: how the web chat view renders a `run_code` turn — the client-side half of the Code Mode UI stack, built on the [host foundation](2026-07-26-code-dispatch-ui-foundation.md) (full-content `tool/code-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. +> Scope: how the web chat view renders a `run_code` turn — the client-side half of the PTC mode UI stack, built on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) (full-content `tool/code-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. ## Problem -With Code Mode enabled, the chat view showed one opaque `run_code` row: raw program text as the summary, sub-calls invisible everywhere. The settled product requirement is the opposite: each sub-call must render *identically* to a native tool call — same row components, same custom registrations, same details panel — while the transcript stays honest about the fact that the model made ONE call. +With PTC mode enabled, the chat view showed one opaque `run_code` row: raw program text as the summary, sub-calls invisible everywhere. The settled product requirement is the opposite: each sub-call must render *identically* to a native tool call — same row components, same custom registrations, same details panel — while the transcript stays honest about the fact that the model made ONE call. ## Decision @@ -23,10 +23,10 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog **Sub-calls flat in the surface flow (fold them into `nodes`).** Rejected: misrepresents the transcript — the model made one call; nesting under the parent preserves the code↔calls association and keeps the fold's model-visible-order invariant untouched. -**Hidden until the parent row expands.** Rejected by product decision: the sub-calls ARE the story of a Code Mode turn; hiding them re-creates the opacity this feature removes. The parent's expand toggle reveals only the program. +**Hidden until the parent row expands.** Rejected by product decision: the sub-calls ARE the story of a PTC mode turn; hiding them re-creates the opacity this feature removes. The parent's expand toggle reveals only the program. **A dedicated sub-call row component.** Rejected: the whole point is identity with native rows; a parallel component would drift. The nest wrapper (indent + left edge) is the only sub-call-specific chrome. ## Consequences -Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership searches the whole tree). Trajectory/waterfall now draw sub-call spans from the dispatch timing pair ([live parallel dispatch](2026-07-26-code-mode-live-parallel-dispatch.md)); without that timing a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom and Runtime suites pin slot dispatch, error states, recursive details resolution, history projection, and reference-stable path copying. +Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership searches the whole tree). Trajectory/waterfall now draw sub-call spans from the dispatch timing pair ([live parallel dispatch](2026-07-26-ptc-live-parallel-dispatch.md)); without that timing a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `ptc-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom and Runtime suites pin slot dispatch, error states, recursive details resolution, history projection, and reference-stable path copying. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md similarity index 66% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md index 6e11f65313..499742d488 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Code Mode 的 chat 渲染——子调用作为父行之下的原生行 +# Agent Note: PTC mode 的 chat 渲染——子调用作为父行之下的原生行 Status: implemented -[English](2026-07-26-code-mode-chat-subcall-rows.md) | 中文 +[English](2026-07-26-ptc-chat-subcall-rows.md) | 中文 -> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 Code Mode UI 栈的客户端侧部分,构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.zh.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.zh.md)所有。 +> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 PTC mode UI 栈的客户端侧部分,构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.zh.md)所有。 ## 问题 -启用 Code Mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的详情面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 +启用 PTC mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的详情面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 ## 决策 @@ -23,10 +23,10 @@ Status: implemented **把子调用平铺进 surface 流(折入 `nodes`)。** 否决:这会歪曲 transcript——模型只发起了一次调用;嵌套在父行之下既保住代码↔调用的关联,也让折叠过程的模型可见顺序不变式原封不动。 -**隐藏子调用,展开父行后才显示。** 由产品决策否决:子调用正是一个 Code Mode 轮次的核心内容;把它们藏起来,等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。 +**隐藏子调用,展开父行后才显示。** 由产品决策否决:子调用正是一个 PTC mode 轮次的核心内容;把它们藏起来,等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。 **专用的子调用行组件。** 否决:本功能的全部要义就在于与原生行保持同一性;一个平行组件必然漂移。嵌套包装层(缩进 + 左侧边线)是子调用唯一的专属视觉装饰。 ## 后果 -自定义 toolview 注册无需额外改动即可适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属会搜索整棵树)。trajectory/waterfall 现在依据分发计时事件对([实时并行分发](2026-07-26-code-mode-live-parallel-dispatch.zh.md))绘制子调用 span;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 与运行时测试套件则锁定 slot 分发、错误状态、递归详情解析、历史投影与引用稳定的路径复制。 +自定义 toolview 注册无需额外改动即可适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属会搜索整棵树)。trajectory/waterfall 现在依据分发计时事件对([实时并行分发](2026-07-26-ptc-live-parallel-dispatch.zh.md))绘制子调用 span;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `ptc-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 与运行时测试套件则锁定 slot 分发、错误状态、递归详情解析、历史投影与引用稳定的路径复制。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml new file mode 100644 index 0000000000..d30a6637f7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.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-26-ptc-dispatch-log-spill.md +2026-07-26-ptc-dispatch-log-spill.md: fa99ceb0b0e8eb078ef29438246cf0d89370bea9 +2026-07-26-ptc-dispatch-log-spill.zh.md: ab115ba0927b6a62d838ca13d5c442e4ae485021 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md new file mode 100644 index 0000000000..fa99ceb0b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md @@ -0,0 +1,31 @@ +# Agent Note: Spilling the durable copy of PTC mode sub-dispatch results + +Status: implemented + +English | [中文](2026-07-26-ptc-dispatch-log-spill.zh.md) + +> Scope: limiting the `tool/code-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-ptc-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) defines the event pair that this listener processes. + +## Problem + +After full-content dispatch logging was added, a `run_code` program that reads a large file wrote the complete rendered text into the session log without a limit or spill policy, while native results were limited to `maxInlineBytes` before logging. This treated the most likely large results differently: sub-calls are intended for bulk data work, and each affected turn added megabytes to the JSONL. + +## Decision + +**A `tools/ptc-dispatch-log` waterfall on the registry, with spill policy as its first listener.** + +- **Extension point**: `tools/ptc-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/code-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `PtcDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. +- **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. +- **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. + +## Alternatives considered + +**Apply a plain byte limit inside the bridge without spill storage.** Rejected: truncation without a locator loses data that replay or UIs may need and restores the less informative "truncated summary" rendering that earlier changes removed. + +**Spill inside the bridge directly by calling `ctx.spillStore` from `ptc.ts`.** Rejected: the registry would require the spill capability. The waterfall keeps this policy with the other spill decisions and allows compositions to omit it; omitting `maxInlineBytes` still makes the listener a no-op. + +**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute can change the program-facing result, so nested calls deliberately skip it and programs receive complete data. The durable copy needs a separate listener that runs after the program has its value. + +## Consequences + +PTC mode dispatch entries in the session log now have the configured byte limit, and the README's Known Limitations entry about unlimited dispatch logging now points here. Old logs with oversized dispatch content still replay because the event fields are unchanged; only future appends contain less text. The web UI renders spilled sub-call output as preview and locator text through the same path as native results, with no special case. diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md new file mode 100644 index 0000000000..ab115ba092 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 将 PTC mode 子分发结果的持久化副本纳入 spill 机制 + +Status: implemented + +[English](2026-07-26-ptc-dispatch-log-spill.md) | 中文 + +> 范围:用既有的 spill 实现限制 `tool/code-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-ptc-dispatch-ui-foundation.zh.md) 有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 定义了该监听器处理的事件对。 + +## 问题 + +加入完整内容的分发日志后,读取大文件的 `run_code` 程序会把完整的渲染文本写进会话日志,既没有上限,也不经过 spill 策略;原生结果则会在记录之前限制在 `maxInlineBytes` 以内。两类结果受到不同处理,而为批量数据工作设计的子调用最可能产生巨大结果;每个受影响的轮次都会让 JSONL 增长数 MB。 + +## 决策 + +**在注册表上增设 `tools/ptc-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** + +- **扩展点**:`tools/ptc-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/code-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 以能力闭包形式接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`PtcDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会等待开放轮次内的全部任务完成。 +- **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过被 spill 的原生结果所用的同一路径读取全文,因此两类结果会渲染出相同的信息。 +- **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 + +## 曾考虑的替代方案 + +**在桥接层内部使用普通字节数上限,不存入 spill。** 否决:没有定位符的截断会丢失回放或 UI 可能需要的数据,还会恢复之前更改已经移除的、信息较少的「截断摘要」渲染。 + +**直接在桥接层内做 spill,即从 `ptc.ts` 调用 `ctx.spillStore`。** 否决:注册表会要求提供 spill 能力。waterfall 把该策略与其他 spill 决策放在一起,并允许组合不加载它;省略 `maxInlineBytes` 时,该监听器仍不执行任何操作。 + +**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 可以修改面向程序的结果,因此嵌套调用有意跳过它,让程序取得完整数据。持久化副本需要一个单独的监听器,在程序取得其值之后运行。 + +## 后果 + +会话日志中的 PTC mode 分发条目现在遵守已配置的字节数上限,README 中关于分发日志不设上限的「已知限制」条目现在指向本篇。携带超大分发内容的旧日志仍可回放,因为事件字段没有变化;只有今后的追加包含更少文本。Web UI 经由与原生结果相同的路径,把被 spill 的子调用输出渲染为预览和定位符文本,不需要特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml new file mode 100644 index 0000000000..4330c215e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.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-26-ptc-dispatch-ui-foundation.md +2026-07-26-ptc-dispatch-ui-foundation.md: 793735a87b734429bfdcba91b0512ce264a8aa1f +2026-07-26-ptc-dispatch-ui-foundation.zh.md: 68cd9c538d71db9f14e2bb4f8eae15049f0c9ad4 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md similarity index 55% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md index 2b919dfb97..793735a87b 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md @@ -1,14 +1,14 @@ -# Agent Note: Code Mode UI foundation — run_code description and native-parity dispatch logging +# Agent Note: PTC mode UI foundation — run_code description and native-parity dispatch logging Status: implemented -English | [中文](2026-07-26-code-dispatch-ui-foundation.zh.md) +English | [中文](2026-07-26-ptc-dispatch-ui-foundation.zh.md) -> Scope: the host-side contract changes that let a UI render a Code Mode turn with the same fidelity as native tool calls — the foundation the other Code Mode UI notes build on. The [Code Mode foundation](2026-06-15-code-mode.md) owns the transport design; this note owns the model-visible `description` parameter, the full-content `tool/code-dispatch` payload, and the temporary `DSH_TOOLS_MODE` enablement switch for the `dsh` config tree. +> Scope: the host-side contract changes that let a UI render a PTC mode turn with the same fidelity as native tool calls — the foundation the other PTC mode UI notes build on. The [PTC mode foundation](2026-06-15-ptc.md) owns the transport design; this note owns the model-visible `description` parameter, the full-content `tool/code-dispatch` payload, and the temporary `DSH_TOOLS_MODE` enablement switch for the `dsh` config tree. ## Problem -A `run_code` turn was opaque in every product surface. The call card's title was the raw program text — unreadable at row width, and unlike `bash` (whose required `description` labels the card while the command rides the expanded input) there was no model-authored label at all. The `tool/code-dispatch` event carried only a 200-char, cwd-normalized `resultSummary` of each sub-call, so no UI could ever show what a sub-call actually returned: the web conversation view ([chat sub-call rows](2026-07-26-code-mode-chat-subcall-rows.md)) renders sub-calls through the exact components that render native `tool/result` cards, and a bounded summary cannot feed a native-parity card. And the `dsh web` composition had no way to enable Code Mode at all — the `tools` row pinned the schema default and the runtime was absent from the tree. +A `run_code` turn was opaque in every product surface. The call card's title was the raw program text — unreadable at row width, and unlike `bash` (whose required `description` labels the card while the command rides the expanded input) there was no model-authored label at all. The `tool/code-dispatch` event carried only a 200-char, cwd-normalized `resultSummary` of each sub-call, so no UI could ever show what a sub-call actually returned: the web conversation view ([chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md)) renders sub-calls through the exact components that render native `tool/result` cards, and a bounded summary cannot feed a native-parity card. And the `dsh web` composition had no way to enable PTC mode at all — the `tools` row pinned the schema default and the runtime was absent from the tree. ## Decision @@ -16,11 +16,11 @@ Three changes, one per obstacle: 1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript. 2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes. -3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-presentation selection owned by the web UI is the design goal, and the env var dies when that lands. +3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`ptc`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-presentation selection owned by the web UI is the design goal, and the env var dies when that lands. ## Alternatives considered -**Keep a bounded summary (raised cap, or a cap + `truncated` flag).** Rejected: the stack's settled requirement is that sub-call rows and details render *identically* to native calls; any cap forces a second, degraded render path plus truncation UI. The cost accepted instead: a program that reads a large file logs the rendered content verbatim on the dispatch event — uncapped, outside spill policy, growing the session log by the same bytes. Spill integration for the logged copy shipped as [code-dispatch log spill](2026-07-26-code-dispatch-log-spill.md). +**Keep a bounded summary (raised cap, or a cap + `truncated` flag).** Rejected: the stack's settled requirement is that sub-call rows and details render *identically* to native calls; any cap forces a second, degraded render path plus truncation UI. The cost accepted instead: a program that reads a large file logs the rendered content verbatim on the dispatch event — uncapped, outside spill policy, growing the session log by the same bytes. Spill integration for the logged copy shipped as [ptc-dispatch log spill](2026-07-26-ptc-dispatch-log-spill.md). **A `--tools-mode` CLI flag or profile key.** Deferred, not rejected: the flag grammar suggests permanence, and the profile json is user config — both would harden a seam the per-session design intends to remove. An env var reads as the workaround it is. @@ -28,4 +28,4 @@ Three changes, one per obstacle: ## Consequences -Session format keeps `SESSION_FORMAT_VERSION` 0 (pre-release churn does not bump; old logs with `resultSummary` simply carry an extra unread field and lack `content` — v0 makes no compatibility promise). Existing code-mode snapshot fixtures were re-recorded. Model-visible surface grew: the `run_code` schema (one required parameter) and every code-mode system prompt/tool-schema snapshot changed. The web UI work builds directly on the new event payload; live per-sub-call running state reshaped this event into a dispatch start/end pair ([live parallel dispatch](2026-07-26-code-mode-live-parallel-dispatch.md)). +Session format keeps `SESSION_FORMAT_VERSION` 0 (pre-release churn does not bump; old logs with `resultSummary` simply carry an extra unread field and lack `content` — v0 makes no compatibility promise). Existing ptc snapshot fixtures were re-recorded. Model-visible surface grew: the `run_code` schema (one required parameter) and every ptc system prompt/tool-schema snapshot changed. The web UI work builds directly on the new event payload; live per-sub-call running state reshaped this event into a dispatch start/end pair ([live parallel dispatch](2026-07-26-ptc-live-parallel-dispatch.md)). diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md similarity index 58% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md index 33599c5689..68cd9c538d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Code Mode 的 UI 基础——run_code 的 description 参数,以及与原生同等保真的分发日志 +# Agent Note: PTC mode 的 UI 基础——run_code 的 description 参数,以及与原生同等保真的分发日志 Status: implemented -[English](2026-07-26-code-dispatch-ui-foundation.md) | 中文 +[English](2026-07-26-ptc-dispatch-ui-foundation.md) | 中文 -> 范围:让 UI 能以与原生工具调用相同的保真度渲染 Code Mode 轮次的宿主侧约定变更,即其他 Code Mode UI Agent Note 赖以构建的基础。传输设计归 [Code Mode 基础](2026-06-15-code-mode.zh.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/code-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用开关,归本篇所有。 +> 范围:让 UI 能以与原生工具调用相同的保真度渲染 PTC mode 轮次的宿主侧约定变更,即其他 PTC mode UI Agent Note 赖以构建的基础。传输设计归 [PTC mode 基础](2026-06-15-ptc.zh.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/code-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用开关,归本篇所有。 ## 问题 -`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/code-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:Web 对话视图([chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.zh.md))会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 Code Mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 +`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/code-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:Web 对话视图([chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md))会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 PTC mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 ## 决策 @@ -16,11 +16,11 @@ Status: implemented 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个界面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件仍仅用于日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 -3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`ptc`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 -**保留有界摘要(提高上限,或上限加 `truncated` 标志)。** 否决:本堆叠 PR(Pull Request)链已敲定的要求是,子调用的行与详情必须与原生调用渲染得*完全一致*;任何上限都会强制引入第二条降级的渲染路径,外加截断 UI。转而接受的代价是:读取大文件的程序会把渲染后的内容原样记录在分发事件上,不设上限、位于 spill 策略之外,并以同样的字节数增大会话日志。持久化副本的 spill 集成已作为 [code-dispatch 日志 spill](2026-07-26-code-dispatch-log-spill.zh.md) 交付。 +**保留有界摘要(提高上限,或上限加 `truncated` 标志)。** 否决:本堆叠 PR(Pull Request)链已敲定的要求是,子调用的行与详情必须与原生调用渲染得*完全一致*;任何上限都会强制引入第二条降级的渲染路径,外加截断 UI。转而接受的代价是:读取大文件的程序会把渲染后的内容原样记录在分发事件上,不设上限、位于 spill 策略之外,并以同样的字节数增大会话日志。持久化副本的 spill 集成已作为 [ptc-dispatch 日志 spill](2026-07-26-ptc-dispatch-log-spill.zh.md) 交付。 **一个 `--tools-mode` CLI(命令行界面)标志或 profile 配置键。** 推迟,而非否决:标志语法暗示永久性,profile JSON 又是用户配置;两者都会固化这个 seam,而按会话选择的设计本就打算移除它。环境变量则如实呈现了它权宜之计的本质。 @@ -28,4 +28,4 @@ Status: implemented ## 后果 -会话格式保持 `SESSION_FORMAT_VERSION` 为 0(预发布阶段的变动不递增版本号;携带 `resultSummary` 的旧日志只是多出一个不被读取的字段并缺少 `content`;v0 不作任何兼容性承诺)。既有的 Code Mode 快照 fixture(测试前置数据)已重新录制。模型可见范围扩大了:`run_code` 的 schema(新增一个必填参数)以及每一份 Code Mode 系统提示词/工具 schema 快照都发生了变化。Web UI 工作直接构建在新的事件载荷之上;每个子调用的实时运行状态已把本事件重塑为一对分发 start/end 事件([实时并行分发](2026-07-26-code-mode-live-parallel-dispatch.zh.md))。 +会话格式保持 `SESSION_FORMAT_VERSION` 为 0(预发布阶段的变动不递增版本号;携带 `resultSummary` 的旧日志只是多出一个不被读取的字段并缺少 `content`;v0 不作任何兼容性承诺)。既有的 PTC mode 快照 fixture(测试前置数据)已重新录制。模型可见范围扩大了:`run_code` 的 schema(新增一个必填参数)以及每一份 PTC mode 系统提示词/工具 schema 快照都发生了变化。Web UI 工作直接构建在新的事件载荷之上;每个子调用的实时运行状态已把本事件重塑为一对分发 start/end 事件([实时并行分发](2026-07-26-ptc-live-parallel-dispatch.zh.md))。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml new file mode 100644 index 0000000000..91ddd782f3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.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-26-ptc-live-parallel-dispatch.md +2026-07-26-ptc-live-parallel-dispatch.md: 0a864411d52e1e6f6a68b11ceb1055f9bc6c5a89 +2026-07-26-ptc-live-parallel-dispatch.zh.md: 1e9f3cdd9159412c93996d77e759e0af34022408 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md similarity index 86% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md index 998152ee43..0a864411d5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md @@ -1,14 +1,14 @@ -# Agent Note: Code Mode live dispatch lifecycle and native-contract parallelism +# Agent Note: PTC mode live dispatch lifecycle and native-contract parallelism Status: implemented -English | [中文](2026-07-26-code-mode-live-parallel-dispatch.zh.md) +English | [中文](2026-07-26-ptc-live-parallel-dispatch.zh.md) -> Scope: the `tool/code-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-code-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-code-mode-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). +> Scope: the `tool/code-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). ## Problem -Two gaps remained after the host foundation and chat sub-call rows shipped. Sub-call rows appeared only when each dispatch *settled* — while one ran, the UI showed nothing for it, so a slow sub-call read as a stalled parent. And the bridge serialized every binding call ("even `Promise.all` executes one at a time"), a placeholder from before tools carried concurrency metadata: `isConcurrencySafe` now exists, the loop scheduler already runs native siblings in bounded pools, and a Code Mode program awaiting three independent reads paid 3× the latency the native path would. +Two gaps remained after the host foundation and chat sub-call rows shipped. Sub-call rows appeared only when each dispatch *settled* — while one ran, the UI showed nothing for it, so a slow sub-call read as a stalled parent. And the bridge serialized every binding call ("even `Promise.all` executes one at a time"), a placeholder from before tools carried concurrency metadata: `isConcurrencySafe` now exists, the loop scheduler already runs native siblings in bounded pools, and a PTC mode program awaiting three independent reads paid 3× the latency the native path would. ## Decision @@ -17,11 +17,11 @@ Two gaps remained after the host foundation and chat sub-call rows shipped. Sub- - **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0. - **Bridge scheduler**: submitted calls are classified at start time via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, `prepare` (pre-execute/guards), the head-of-line `finalize`/`finish` commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (`fillPool` awaits `startCall` then `commitReady`). Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a `Config` field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn. - **Client**: Runtime's `ToolCallTree` stores a start event as a `RunningToolCall` child and projects it through the parent's recursive `subCalls` (rows derive the running ring from that shape, exactly as for native in-flight calls). Its settle replaces the private-index entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering. -- **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every code-mode snapshot. +- **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every ptc snapshot. ## Alternatives considered -**Unrestricted parallelism (let `Promise.all` overlap everything).** Rejected: writes could race; the native scheduler exists precisely because the tool, not the caller, owns the safety claim. One concurrency vocabulary across native and Code Mode was the settled requirement. +**Unrestricted parallelism (let `Promise.all` overlap everything).** Rejected: writes could race; the native scheduler exists precisely because the tool, not the caller, owns the safety claim. One concurrency vocabulary across native and PTC mode was the settled requirement. **Emit the start event at submission instead of pool entry.** Rejected: a submission-time start would show queued-but-never-run calls as "running" and would force a third "abandoned" terminal event to reconcile the log. Start-at-entry keeps the invariant *started ⇔ settles exactly once* and needs no third event. @@ -29,4 +29,4 @@ Two gaps remained after the host foundation and chat sub-call rows shipped. Sub- ## Consequences -Programs get native-grade latency for independent reads with no new model-side API — `Promise.all` simply works better, and prompt guidance changed accordingly. The web UI shows per-sub-call running rings live (fixture emits start/settle pairs; jsdom pins the running shape; the runtime spec pins in-place settlement, out-of-order completion, and callTime pairing). Trajectory/waterfall sub-call spans draw truthful timing from the pair. Spill bounding ([code-dispatch log spill](2026-07-26-code-dispatch-log-spill.md)) inherits the settle event as its single bounding point. +Programs get native-grade latency for independent reads with no new model-side API — `Promise.all` simply works better, and prompt guidance changed accordingly. The web UI shows per-sub-call running rings live (fixture emits start/settle pairs; jsdom pins the running shape; the runtime spec pins in-place settlement, out-of-order completion, and callTime pairing). Trajectory/waterfall sub-call spans draw truthful timing from the pair. Spill bounding ([ptc-dispatch log spill](2026-07-26-ptc-dispatch-log-spill.md)) inherits the settle event as its single bounding point. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md similarity index 84% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md index e70e23a326..1e9f3cdd91 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Code Mode 的实时分发生命周期,以及复用原生约定的并行执行 +# Agent Note: PTC mode 的实时分发生命周期,以及复用原生约定的并行执行 Status: implemented -[English](2026-07-26-code-mode-live-parallel-dispatch.md) | 中文 +[English](2026-07-26-ptc-live-parallel-dispatch.md) | 中文 -> 范围:`tool/code-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.zh.md)与 [chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.zh.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 +> 范围:`tool/code-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)与 [chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 ## 问题 -宿主侧基础与 chat 子调用行交付之后仍留有两个缺口。子调用行过去只在每次分发*结算*后才出现:某次分发运行期间,UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在,agent loop(智能体循环)调度器早已在有界并发池中运行原生兄弟调用,而一个等待三个独立读取的 Code Mode 程序,付出的延迟却是原生路径的 3 倍。 +宿主侧基础与 chat 子调用行交付之后仍留有两个缺口。子调用行过去只在每次分发*结算*后才出现:某次分发运行期间,UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在,agent loop(智能体循环)调度器早已在有界并发池中运行原生兄弟调用,而一个等待三个独立读取的 PTC mode 程序,付出的延迟却是原生路径的 3 倍。 ## 决策 @@ -17,11 +17,11 @@ Status: implemented - **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响;格式保持 v0。 - **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 约定),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。 - **客户端侧**:运行时的 `ToolCallTree` 把 start 事件存为 `RunningToolCall` 子级,并通过父级递归的 `subCalls` 投影出来(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致)。其结算事件会原位替换私有索引中的条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。 -- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实约定(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 Code Mode 快照都已重新录制。 +- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实约定(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 PTC mode 快照都已重新录制。 ## 曾考虑的替代方案 -**不加限制的并行(让 `Promise.all` 重叠一切)。** 否决:写操作可能产生竞态;原生调度器之所以存在,正是因为安全性声明归工具所有,而不归调用方。原生与 Code Mode 使用同一套并发词汇,是已敲定的要求。 +**不加限制的并行(让 `Promise.all` 重叠一切)。** 否决:写操作可能产生竞态;原生调度器之所以存在,正是因为安全性声明归工具所有,而不归调用方。原生与 PTC mode 使用同一套并发词汇,是已敲定的要求。 **在提交时而非入池时发出 start 事件。** 否决:提交即发 start 会把排了队却从未运行的调用显示成「运行中」,还得强行引入第三种「已放弃」终态事件才能使日志自洽。入池才发 start 保住了*已启动 ⇔ 恰好结算一次*这一不变式,且不需要第三种事件。 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -程序不需要任何新的模型侧 API,独立读取就获得了原生级的延迟:`Promise.all` 直接变得更好用,提示词指引也随之修改。Web UI 实时显示每个子调用的运行指示环:fixture(测试前置数据)发出成对的 start/settle 事件;jsdom 锁定运行中形状;运行时测试锁定原位结算、乱序完成与 callTime 配对。trajectory/waterfall 的子调用 span 从这对事件取得如实的计时。spill 边界划定([code-dispatch 日志 spill](2026-07-26-code-dispatch-log-spill.zh.md))则以结算事件作为唯一的边界点。 +程序不需要任何新的模型侧 API,独立读取就获得了原生级的延迟:`Promise.all` 直接变得更好用,提示词指引也随之修改。Web UI 实时显示每个子调用的运行指示环:fixture(测试前置数据)发出成对的 start/settle 事件;jsdom 锁定运行中形状;运行时测试锁定原位结算、乱序完成与 callTime 配对。trajectory/waterfall 的子调用 span 从这对事件取得如实的计时。spill 边界划定([ptc-dispatch 日志 spill](2026-07-26-ptc-dispatch-log-spill.zh.md))则以结算事件作为唯一的边界点。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 1e3ba8b79e..2d0ef48750 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md -2026-07-27-web-session-search.md: e467f4802e5a9b4b6bfef6b3f568b33c0b1fa7a6 -2026-07-27-web-session-search.zh.md: d440f0ce56668f34057959135791e9deca097b14 +2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2 +2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index e467f4802e..bb41028ec4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -16,7 +16,7 @@ The host gateway exposes `session.search` through the existing typed RPC stack. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. -The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives beside the response schema that enforces it in `dsh-host-apiproxy`, and `SessionRuntime.searchResultLimit` re-exposes that constant for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound that the schema's fixed `max` forbids, and would leave the same fact with two homes in the same module. +The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives with the request and result types in `@deepseek-ai/dsh-api-session-controller/types`; Session Controller enforces it, and `ClientSessions.searchResultLimit` re-exposes it for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The Connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound and leave the same fact with two owners. Content matching inherits the SQLite backend's normalized literal token/phrase semantics. The shared semantic projection excludes reasoning blocks, so UI search never returns a model's private reasoning as a hit or snippet; the derived-index schema version advances so existing persistent indexes rebuild without the former documents. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index d440f0ce56..9f03c42cfb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -16,7 +16,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 -结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionRuntime.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。 +结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。 内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 684ebea047..0838d23d51 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95 -2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2 +2026-07-27-web-subagent-conversations.md: 5ae4c22627a5f39547f1ca7f22bb9794b74e4340 +2026-07-27-web-subagent-conversations.zh.md: 79065872837ff3dd9e22f4be660991e9c540c7c0 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index c897d345d9..5ae4c22627 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -51,7 +51,7 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part ## Host adapter and wire contract -`@deepseek-ai/dsh-host-apiproxy` owns a browser-safe `subagents` domain: +`@deepseek-ai/dsh-subagent` owns the browser-safe generated `subagent` Remote namespace: - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`. - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent. @@ -63,7 +63,7 @@ Viewing persisted history creates no mux subscription by itself. When a follow-u The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents. -The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). +The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). ## Client object layer and presentation diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0fdfd19b6d..7906587283 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -51,7 +51,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 宿主适配器与协议约定 -`@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域: +`@deepseek-ai/dsh-subagent` 拥有浏览器安全的生成 `subagent` Remote 命名空间: - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。 @@ -63,7 +63,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。 -适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)。 +适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。 ## 客户端对象层与呈现 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml index f8d99db60e..ea9bcff6d2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-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 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md -2026-07-28-skill-invocation-policy.md: 7a4f83ecae3dea82ab6864e748c9d8b8599f6bfc -2026-07-28-skill-invocation-policy.zh.md: 8d66af69b7f45c5d76d18963df6921eaec2e1e8c +2026-07-28-skill-invocation-policy.md: 918283bd028ec75faee965c35dfac494e9ceafad +2026-07-28-skill-invocation-policy.zh.md: ddf3a334561d2077c07f479792228a353cb926c0 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md index 7a4f83ecae..918283bd02 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -18,7 +18,7 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. -The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skills/list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. These rules permit all four combinations: diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md index 8d66af69b7..ddf3a33456 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -18,7 +18,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。 -面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill 对等依赖(peer dependency)变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill 对等依赖(peer dependency)变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skills/list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 这些规则允许以下四种组合: diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml index 89d0986ee9..56a7ab1fb8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.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 .agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md -2026-07-28-todo-plan-clears-on-next-turn.md: 55a0d4f307bef5cd04add0baae220ea763772ee9 -2026-07-28-todo-plan-clears-on-next-turn.zh.md: c5fb809694c680951e1f2238d5596e771e8bcec8 +2026-07-28-todo-plan-clears-on-next-turn.md: b8a199840c683bc1ecb79aa8c9c4ea330da5e474 +2026-07-28-todo-plan-clears-on-next-turn.zh.md: bbe6fd35b5965c19864e3d181a845993e842132d diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md index 55a0d4f307..b8a199840c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md @@ -14,7 +14,7 @@ The standing plan is the latest `todo/write` that is not followed by a later `tu ### Host projection (web) -`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Carriers (`dsh-host-apiproxy`) serve that value on the history tail `projections` block and push `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots. +`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Session Controller serves that value on the history tail `projections` block and pushes `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots. ### TUI live path diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md index c5fb809694..bbe6fd35b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 宿主投影(web) -`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。载体(`dsh-host-apiproxy`)在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 +`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。Session Controller 在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;Web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 ### TUI 实时路径 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 824846180a..85a24356aa 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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 .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: e6e590b2a97654b8b68d5a9842de10818f544088 -2026-07-28-tool-call-file-open-in-os.zh.md: eb600a69cb2a2c3cc0d7463519d3de4dce76047b +2026-07-28-tool-call-file-open-in-os.md: 4655af04243b28284cf46c058a472ee658d9a035 +2026-07-28-tool-call-file-open-in-os.zh.md: 769aeaeaa74af54aef60521505a8ed107500fa8a diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index e6e590b2a9..4655af0424 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -10,9 +10,9 @@ Chat tool rows treated the whole summary line as a click target that opened the ## Decision -File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspaceRuntime.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. +File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the chat view resolves relative paths against the addressed Session's cwd when it is known. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. +`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `session/canOpenWorkspacePath` result is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered @@ -23,7 +23,7 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). The Client withholds `host.openPath` on non-loopback pages; every exposed Host invocation still requires the browser session. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). The Client withholds the file-open gesture on non-loopback pages; every exposed Host invocation still requires the browser session. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index eb600a69cb..769aeaeaa7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经 `WorkspaceRuntime.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 +文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`;聊天视图会在目标 Session 的 cwd 已知时据此解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是一元 RPC,与每个 Host API 方法一样要求通过 Host/Origin 校验和浏览器会话认证。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `session/canOpenWorkspacePath` 结果为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 @@ -23,7 +23,7 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。Client 在非 loopback 页面不提供 `host.openPath`;每次已暴露的 Host 调用仍要求浏览器会话。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。Client 在非 loopback 页面不提供文件打开手势;每次已暴露的 Host 调用仍要求浏览器会话。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 ## 风险 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 3b1f9f9754..6b3207b92f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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 .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: a607d160f3529b4e7eb94e34be5a0a91a4010619 -2026-07-28-web-terminal-card.zh.md: 4c7fa5d35dfc85ce38db13402e3e56ccfb519584 +2026-07-28-web-terminal-card.md: 2fd2b429c0424d1ae7421fe4c679c46902db62fd +2026-07-28-web-terminal-card.zh.md: 1405c03766316379ceb2c97ede153704268c35e6 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index a607d160f3..2fd2b429c0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -49,7 +49,7 @@ One premise of that split has since weakened: [tool rows stopped being details-p `TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. -A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the code-dispatch wire is that boundary's own change. +A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the ptc-dispatch wire is that boundary's own change. Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 4c7fa5d35d..1405c03766 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -51,7 +51,7 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode `TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。 -在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于该边界自身的改动。 +在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 ptc-dispatch wire 属于该边界自身的改动。 内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 8cf0c30e2b..b8b98a6ecb 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.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 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md -2026-07-29-ask-question-web-presentation.md: fd6c326ccadc83cb9d2edc0151dd94984f2bca8b -2026-07-29-ask-question-web-presentation.zh.md: c26fe91c91280c3f5596a30a3e3483ed1fa784b4 +2026-07-29-ask-question-web-presentation.md: 280671570ca015457ab3c17241cea3e973b88fd2 +2026-07-29-ask-question-web-presentation.zh.md: 9ad687d655de19ea913ddc5255fb6426ef0fbb77 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md index fd6c326cca..280671570c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -14,6 +14,10 @@ Separately, the composer visuals had drifted from the current design: an expand- A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `tool.call.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrowed to `PendingWait<'approval'>` and `ChatView` filtered the pending list to approval waits, leaving the placeholder card to approvals alone; the approval composer takeover ([web permission and approval](2026-07-23-web-permission-and-approval.md)) has since removed it entirely. +A successful row keeps the collapsed transcript to its one-line verdict and replaces generic JSON in the expanded body with a read-only question transcript. The presenter validates questions from call args and answers from result content, pairs them by the echoed stable `id`, preserves call order, and renders each model-authored question in a muted label followed by its selected and custom answer lines in primary text. A skipped question shows the localized `Not answered` verdict. The card caps its height and scrolls internally. Invalid JSON, duplicate ids, missing ids, count mismatches, unknown answer ids, and invalid visible fields all retain the generic input/output card instead of presenting a partial or incorrectly paired transcript. + +A cancelled or interrupted row has no answer payload to pair. Its expanded card shows a localized set-level verdict that no answers were submitted followed by the original model-authored questions; it does not label cancellation as a per-question skip or fabricate answer records. Invalid call args retain the generic diagnostic card. Cancellation uses the neutral settled state because the user chose it deliberately; interruption keeps the amber stopped state. + The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim. @@ -24,11 +28,13 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover **Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface. -**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list. +**Show the questions or answers in the collapsed transcript row.** Rejected: the composer takeover owns answer collection, and the row convention (`todo_write`) keeps the collapsed line scannable. The row therefore reports only the outcome until expanded; its expanded body owns the read-only question transcript. + +**Keep raw input and output JSON in the expanded body.** Rejected: the payload preserves all information but makes the user's own answers or the cancelled questions difficult to scan. The structured view presents the same authored text while retaining raw JSON as the fail-closed fallback when question parsing or answer pairing is not trustworthy. **Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere. -**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale. +**Keep the row verdicts in English.** Initially deferred, then superseded when Client UI copy became locale-owned: the current conversation dictionaries localize the row verdicts and the expanded card's skipped-answer label, while model-authored questions and answers remain verbatim. **Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel. @@ -36,10 +42,10 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover `ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. -The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. +The expanded transcript adds a typed, plain-data question-card model to the shared `ToolRow`; other tool views retain their existing generic or specialized cards. The question row reads only persisted call and result fields and does not add a Host presentation field. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. `ui-user-questions` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. ## Verification -`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. +`ui-tool` tests pin the row's waiting/answered/skipped/cancelled/interrupted matrix, readable id-based pairing, selected and custom answer lines, no-answer verdicts, and fail-closed fallback. Keyless assembled-Web snapshots expand successful and cancelled question rows and record their readable transcripts. `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index c26fe91c91..9ad687d655 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -14,6 +14,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, 一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `tool.call.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 曾收窄为 `PendingWait<'approval'>`,`ChatView` 曾将待处理列表过滤为仅审批等待,使占位卡片只服务于审批;其后审批输入区接管([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md))已将它彻底移除。 +成功的问题行仍让折叠后的会话记录只显示单行裁决,并在展开内容中用只读问答记录取代通用 JSON。presenter 校验调用参数中的问题与结果内容中的回答,按回显的稳定 `id` 配对、保持调用顺序,并将每段模型撰写的问题显示为弱化标签,下方用主要文字显示已选项和自定义回答。被跳过的问题显示本地化的 `未回答` 裁决。卡片限制最大高度并在内部滚动。无效 JSON、重复 id、缺少 id、数量不符、未知回答 id 与无效可见字段都保留通用输入/输出卡片,不呈现不完整或错误配对的记录。 + +已取消或已中断的问题行没有可供配对的回答载荷。其展开卡片先显示本地化的整组裁决,说明未提交回答,再列出模型撰写的原始问题;它不会把取消标成逐题跳过,也不会虚构回答记录。无效调用参数保留通用诊断卡片。取消是用户主动选择,使用中性 settled 状态;中断保留琥珀色 stopped 状态。 + 输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过 slot inject face 向条目提供绑定命名空间的翻译器和作为 hooks compartment 来源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。 @@ -24,11 +28,13 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, **继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。 -**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。 +**在折叠的会话记录行显示问题或回答。** 否决:输入区接管拥有回答收集,而行的约定(`todo_write`)让折叠单行保持易扫描。因此该行在展开前只报告结果,展开内容拥有只读问答记录。 + +**在展开内容中保留原始输入与输出 JSON。** 否决:载荷保留了全部信息,却让用户自己的回答或已取消的问题难以浏览。结构化视图展示相同的作者文本;问题解析或回答配对不可信时仍以原始 JSON 作为 fail-closed 回退。 **用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。 -**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。 +**让行内裁决保持英文。** 最初延期,随后在 Client UI 文案改由 locale 管理时被取代:当前 conversation 词典本地化行内裁决与展开卡片的未回答标签,而模型撰写的问题和回答保持原样。 **保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。 @@ -36,10 +42,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, `ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的 slot 注册。专用的 `todo-row.module.css` 已删除。 -行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 +展开问答记录为共享 `ToolRow` 增加类型化的纯数据问题卡片模型;其他工具视图保留原有的通用或专用卡片。问题行只读取已持久化的调用与结果字段,不增加 Host 呈现字段。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 `ui-user-questions` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其约定(`QuestionComposerInjected`)与消费方一起放在 `contract/slots.ts`。 ## 验证 -`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和 slot 注册;`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 +`ui-tool` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted 矩阵、可读的 id 配对、已选项与自定义回答行、无回答裁决和 fail-closed 回退。无密钥的组装 Web 快照分别展开成功与取消的问题行并记录其可读内容。`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 00ee8cb309..fbddedc9e1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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 .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 0b445d6eccdd9aa1651b64f084a96d4d674a4f12 +2026-07-30-deepseek-onboarding-credential-setup.md: c2e9a1251a4666d9b7109d284a1588640d630332 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: e94f55949eb472d8deb524c33e9760155db0ddcf diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 87533e7a55..c2e9a1251a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. +**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm/listProviders`, `llm/listConfigurableProviders`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials/describe`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. **The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md). diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 0b445d6ecc..e94f55949e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.zh.md)持有这次 settings 读取及其失效顺序。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm/listProviders`、`llm/listConfigurableProviders`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials/describe` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.zh.md)持有这次 settings 读取及其失效顺序。 **设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.zh.md)持有。 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml deleted file mode 100644 index b811dee04f..0000000000 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: fe298b9a7cf14856f1bb10ac7f02d28ea3b67842 -2026-07-31-code-mode-language-dispatch.zh.md: a9a358e85c433fc406017cb2967fbd52e42bc1d4 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index d00a3fb636..2f2c4740b0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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 .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 8d1af039c99fe6616745340fd1ef78b62e15b0ca -2026-07-31-even-out-shipped-tool-rosters.zh.md: b79486516dd3f7b54e27a3e7870ce42cd84a2ebd +2026-07-31-even-out-shipped-tool-rosters.md: 4e6b4b0e0ad89ce95d731dac21a8b55a24663be5 +2026-07-31-even-out-shipped-tool-rosters.zh.md: 4be6678cc2f728c6f69cbf97462089a6b45c736a diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 8d1af039c9..4e6b4b0e0a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -52,7 +52,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th **Sandbox the TUI in the same change.** Rejected as a separate decision that does not belong in a roster change: the TUI mounts unrestricted executors, and replacing them alters what an existing surface does rather than what it offers. That decision needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting. -**Enable Code Mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. +**Enable PTC mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `ptc` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. **Mount an MCP server by default.** Rejected because a shipped default would have to name one, and any choice spawns a third-party child process on every user's machine outside the sandbox. The dependency ships instead. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index b79486516d..4be6678cc2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -52,7 +52,7 @@ Status: implemented **在同一次改动里给 TUI 加沙箱。** 不予采纳,因为这是一个不属于工具清单改动的独立决定:TUI 挂的是不受限执行器,替换它们会改变一个既有 surface 做什么,而非它提供什么。这个决定需要自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升权请求在那里会 fail-closed,而不是弹出提示。 -**开启 Code Mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 +**开启 PTC mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `ptc` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 **默认挂一台 MCP 服务器。**否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index aaa3cc3cc8..92a54e0263 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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 .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd -2026-07-31-permission-default-for-new-sessions.zh.md: c0f450c8efca8647e3058fb305724cf0a554cc8d +2026-07-31-permission-default-for-new-sessions.md: b5bb72ee179760e4b58ae5ca1124f9948ed24d5d +2026-07-31-permission-default-for-new-sessions.zh.md: 9383a1b184f610d2995fb3d6982d3f4001729822 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index ebf7fe3971..b5bb72ee17 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -16,7 +16,7 @@ The service reads the current Settings value synchronously at `session/created`. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. -ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. +The Settings Controller exposes the registered `permission` namespace through its redacted Remote view. This is a local presentation decision, not a general registration flag or a `local-client` access model. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index c0f450c8ef..9383a1b184 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -16,7 +16,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 -ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.zh.md)),不会宣告模型拓扑。 +Settings Controller 通过脱敏的 Remote 视图暴露已注册的 `permission` namespace。这是局部的呈现决策,而不是通用注册标志或 `local-client` 访问模型。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.zh.md)),不会宣告模型拓扑。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.i18n.yaml new file mode 100644 index 0000000000..b583e43bfc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.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-31-ptc-language-dispatch.md +2026-07-31-ptc-language-dispatch.md: c23e7b7ee1ebd85c98a92621721fca22eef48d63 +2026-07-31-ptc-language-dispatch.zh.md: 2b0110631761744a5067e541b4baf2a7d50a4d21 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md similarity index 88% rename from .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md rename to .agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md index fe298b9a7c..c23e7b7ee1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md @@ -1,25 +1,25 @@ -# Agent Note: Code Mode language dispatch and the Python SDK renderer +# Agent Note: PTC mode language dispatch and the Python SDK renderer Status: implemented -English | [中文](2026-07-31-code-mode-language-dispatch.zh.md) +English | [中文](2026-07-31-ptc-language-dispatch.zh.md) ## Problem -Code Mode generated one SDK flavor: TypeScript. `ToolRuntime` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime. +PTC mode generated one SDK flavor: TypeScript. `ToolRuntime` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime. -This is the tool-facing half of the multi-language Code Mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately. +This is the tool-facing half of the multi-language PTC mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately. ## Decision Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at prompt assembly, against two parallel tables in `dsh-tools`: -- `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. -- `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. +- `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: ptc`/`both` runtime whose language is absent from the table, naming the known languages. +- `RUN_CODE_FLAVORS` (ptc.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, reached by definition readers and `schemas()`, of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes `requireCodeRuntime` first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the prose that names the well-known values instead of deriving them (the seam's `dsh-code-runtime` README pair, its `CodeRuntime.language` JSDoc, and the `docs/subsystems/code-runtime.md` pair; this package's own README pair and its `Config.mode` JSDoc — no gate checks any of it), with no `agent-loop` or registry-structure change. -`code-mode.ts` depends only on the runtime Service Definition (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer is therefore independent of the Python protocol and backend — it needs only the service's `language` field. +`ptc.ts` depends only on the runtime Service Definition (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer is therefore independent of the Python protocol and backend — it needs only the service's `language` field. ### The Python SDK renderer @@ -32,7 +32,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Alternatives considered - **A `language` config field on `ToolRuntime`.** Deployment would then have two places to name the language (the loaded runtime and the tools config) that can disagree; the loaded runtime is the single source of truth, so the registry reads it rather than duplicating it. -- **Importing the Python backend into `code-mode.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable. +- **Importing the Python backend into `ptc.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable. - **A default renderer for an unknown language.** A silent fallback would emit a TypeScript SDK over, e.g., a Ruby runtime — the model would see instructions in the wrong language. Failing loud at assembly is the repository's misconfiguration stance. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.zh.md similarity index 88% rename from .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md rename to .agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.zh.md index a9a358e85c..2b01106317 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.zh.md @@ -1,25 +1,25 @@ -# Agent Note: Code Mode 语言分发与 Python SDK 渲染器 +# Agent Note: PTC mode 语言分发与 Python SDK 渲染器 Status: implemented -[English](2026-07-31-code-mode-language-dispatch.md) | 中文 +[English](2026-07-31-ptc-language-dispatch.md) | 中文 ## 问题 -Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRuntime` 为 `tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK,而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。 +PTC mode 只生成一种 SDK 形态:TypeScript。`ToolRuntime` 为 `tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK,而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。 -这是多语言 Code Mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.zh.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。 +这是多语言 PTC mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.zh.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。 ## 决策 语言选择就是对 `ctx.codeRuntime.language` 的查表,在提示词装配时惰性解析,查 `dsh-tools` 里两张平行的表: -- `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 -- `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 +- `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: ptc`/`both` 运行时,并列出已知语言。 +- `RUN_CODE_FLAVORS`(ptc.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的回调内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,由直读 definition 的读者与 `schemas()` 到达,其中 doc-catalog 采集是唯一已交付的一个,而它们都不会喂给模型,因为组装路径先过 `requireCodeRuntime`)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及点名已知值而非从中派生的散文(seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/subsystems/code-runtime.md` 双语对;本包自己的 README 双语对与它的 `Config.mode` JSDoc,无任何 gate 检查其中任何一处),不动 `agent-loop`,也不动注册表结构。 -`code-mode.ts` 只依赖运行时 Service Definition(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于 Python 协议和后端——它只需要服务的 `language` 字段。 +`ptc.ts` 只依赖运行时 Service Definition(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于 Python 协议和后端——它只需要服务的 `language` 字段。 ### Python SDK 渲染器 @@ -32,7 +32,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRuntime` 为 `tools:sdk ## 考虑过的替代方案 - **在 `ToolRuntime` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真源,故注册表读取它而不复制它。 -- **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR(Pull Request)先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。 +- **把 Python 后端 import 进 `ptc.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR(Pull Request)先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。 - **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml index a54ef0e945..b023332a1e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.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 .agents/notes/implemented/feature/2026-07-31-web-default-search.md -2026-07-31-web-default-search.md: efec6e1e94089d3bbd79296ff0eb2cb55ce005b3 -2026-07-31-web-default-search.zh.md: 825f0ee3f899c108039f6a0db59f0dfe2822cb72 +2026-07-31-web-default-search.md: dfd76176aa03741df38f60c6d30116f87ced4106 +2026-07-31-web-default-search.zh.md: 19fe20d7573accbcef45ab4db8c339335e2cc08b diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md index efec6e1e94..dfd76176aa 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md @@ -34,4 +34,4 @@ The default mount does not create a Web-specific permission policy. `web_search` ## Consequences -Native model requests on every shared-base surface carry the `web_search` schema and search guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The shipped Web `cordis`, `code`, and `standard` presets additionally expose `web_fetch` with public-address enforcement and no per-call approval. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. Composition smokes pin the shared search roster and per-preset fetch choices; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. +Native model requests on every shared-base surface carry the `web_search` schema and search guidance; Web/headless PTC mode exposes the same search capability beneath `run_code`. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The shipped Web `cordis`, `ptc`, and `standard` presets additionally expose `web_fetch` with public-address enforcement and no per-call approval. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. Composition smokes pin the shared search roster and per-preset fetch choices; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md index 825f0ee3f8..19fe20d757 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official` 与 `fetchProvider: http`,同时挂载 `dsh-web-search-deepseek`、`dsh-web-fetch-http`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。因此,共享 base 只会暴露 `web_search`,除非产品 preset 启用抓取;已交付的 Web `cordis`、`code` 与 `standard` preset 会启用抓取。显式提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。[Web 能力 seam 决策](../architecture/2026-06-24-web-capability-seam.zh.md)负责公开抓取安全策略与 Web preset 默认值。 +`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official` 与 `fetchProvider: http`,同时挂载 `dsh-web-search-deepseek`、`dsh-web-fetch-http`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。因此,共享 base 只会暴露 `web_search`,除非产品 preset 启用抓取;已交付的 Web `cordis`、`ptc` 与 `standard` preset 会启用抓取。显式提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。[Web 能力 seam 决策](../architecture/2026-06-24-web-capability-seam.zh.md)负责公开抓取安全策略与 Web preset 默认值。 DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。 @@ -34,4 +34,4 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据 ## 后果 -每个共享 base surface 的原生模型请求都会携带 `web_search` schema 与搜索指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。已交付的 Web `cordis`、`code` 与 `standard` preset 还会暴露 `web_fetch`,实施公开地址强制校验且无需逐次审批。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。组合冒烟测试会固定共享搜索清单与各 preset 的抓取选择;构建后组合配置的转储固定已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 +每个共享 base surface 的原生模型请求都会携带 `web_search` schema 与搜索指引;Web/无头 PTC 模式 通过 `run_code` 公开相同的搜索能力。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。已交付的 Web `cordis`、`ptc` 与 `standard` preset 还会暴露 `web_fetch`,实施公开地址强制校验且无需逐次审批。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。组合冒烟测试会固定共享搜索清单与各 preset 的抓取选择;构建后组合配置的转储固定已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 2d37aaba9f..dd8a8cdb84 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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 .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: a492356eccba9f272ee216777eb518750c7b6b62 -2026-07-31-web-telemetry-default-mount.zh.md: 3d852229c069ae32f328c8dae38cfdf1744c7293 +2026-07-31-web-telemetry-default-mount.md: aea90ef928afaa7669df8046fa98f16c575618f0 +2026-07-31-web-telemetry-default-mount.zh.md: 122b7ad593c18cd547ade835827e8903d34da441 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index a492356ecc..aea90ef928 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,12 +10,12 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps the mounted row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. +The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) originally kept the mounted row in `DISABLED` mode; the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md) now resolves an unset mode to `FEEDBACK_ONLY`, uploading only when the user records `/feedback`. The endpoint alone still does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. | Ruling | Value | Rationale | |---|---|---| | Mount surface | `packages/bundle/base/cordis.patch.yml` | One capability row for every profile that loads the shared base | -| Sharing mode | `DSH_TELEMETRY_MODE`, default `DISABLED`; explicit `FULL` or `FEEDBACK_ONLY` opts in | A fresh profile makes no telemetry network request, while internal deployments retain both upload policies | +| Sharing mode | `DSH_TELEMETRY_MODE`, default `FEEDBACK_ONLY` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)); explicit `FULL` or `DISABLED` overrides | A fresh profile uploads only when the user records `/feedback`, while internal deployments retain both explicit policies | | Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs | | Hard opt-out | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables the row | The launcher patch takes effect before load-time transport validation and overrides every configured mode | | Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) in uploading modes | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | @@ -23,7 +23,7 @@ The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the | Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth | | CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in GitHub workflows | Defense in depth keeps test sessions local even when a job explicitly selects an uploading mode | -The base bundle test pins the shipped `DISABLED` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. +The base bundle test pins the shipped `FEEDBACK_ONLY` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. ## Alternatives considered @@ -35,6 +35,6 @@ The base bundle test pins the shipped `DISABLED` mode expression, the backend su ## Consequences -- A developer running `dsh web` without telemetry configuration makes no telemetry network request. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. +- A developer running `dsh web` without telemetry configuration makes no telemetry network request until they record `/feedback`. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. - **No redaction rule is mounted**: explicitly enabled exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `session-telemetry/record` rules first — the redaction rule, remaining identity Resource attributes, and usage metrics remain separate deployment work. The anonymous user id ships through the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md). - Test rigs remain local by default; explicit uploading-mode tests provide their own collector and mode. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 3d852229c0..122b7ad593 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,12 +10,12 @@ Status: implemented ## 决策 -共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让已挂载配置项保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 +共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)最初让已挂载配置项保持 `DISABLED` 模式;[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)现在把未设置的模式解析为 `FEEDBACK_ONLY`,只在用户记录 `/feedback` 时上传。仅配置 endpoint 仍不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 | 决策项 | 取值 | 理由 | |---|---|---| | 挂载面 | `packages/bundle/base/cordis.patch.yml` | 每个加载共享基础组合包的 profile 都使用同一个能力配置行 | -| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `DISABLED`;显式设置 `FULL` 或 `FEEDBACK_ONLY` 即启用 | 新 profile 不发出遥测网络请求,内部部署仍可使用两种上传策略 | +| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `FEEDBACK_ONLY`([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md));显式设置 `FULL` 或 `DISABLED` 即覆盖 | 新 profile 只在用户记录 `/feedback` 时上传,内部部署仍可使用两种显式策略 | | endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 | | 硬性退出 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即禁用该配置行 | 启动器 patch 在加载期传输校验之前生效,并覆盖所有已配置模式 | | 上报节奏 | 上传模式中为 `processor.scheduledDelayMillis: 10000`(10s/批) | 在会话运行期间流式上报,而非仅在退出时上报;崩溃至多丢失最后一个尚未导出间隔内的数据 | @@ -24,7 +24,7 @@ Status: implemented | CI 隔离 | GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | 即使 CI 任务显式选择上传模式,纵深防御也会让测试会话留在本地 | -基础组合包测试固定交付的 `DISABLED` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 +基础组合包测试固定交付的 `FEEDBACK_ONLY` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 ## 考虑过的替代方案 @@ -36,6 +36,6 @@ Status: implemented ## 后果 -- 开发者运行没有遥测配置的 `dsh web` 时,不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 +- 开发者运行没有遥测配置的 `dsh web` 时,在记录 `/feedback` 之前不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 - **没有挂载任何脱敏规则**:显式启用的导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须先挂载 `session-telemetry/record` 规则;脱敏规则、其余身份 Resource 属性和使用情况指标仍是独立的部署工作。匿名 user id 由[匿名 user id Note](2026-07-31-telemetry-anonymous-user-id.zh.md)交付。 - 测试载具默认将数据留在本地;显式启用上传模式的测试提供自己的 collector 和模式。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 902227d012..5a5c329383 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.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 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: 611f012e0201fa9002ea473ef9a107841bf83bcc -2026-07-31-web-workspace-file-links.zh.md: 6e8806ab41de88bf59e4f7bf642f78c3bfe50cb3 +2026-07-31-web-workspace-file-links.md: ddc093e1ce5e646f6b96f1517b2a26c9e10fe423 +2026-07-31-web-workspace-file-links.zh.md: f463de969bab7a47145abbbd46363b8807bcc673 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 611f012e02..ddc093e1ce 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -10,7 +10,7 @@ English | [中文](2026-07-31-web-workspace-file-links.zh.md) A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal. -Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: `ToolRow` already renders a mutation or read row's path as a real button wired to `host.openPath`, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability. +Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: `ToolRow` already renders a mutation or read row's path as a real button wired to `session/openWorkspacePath`, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability. ## Decision @@ -18,7 +18,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. -**Opening stays the Host's job, and prefers the default browser.** `host.openPath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive. +**Opening stays the Host's job, and prefers the default browser.** `session/openWorkspacePath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive. **Serving workspace files over HTTP is out of scope, and so are non-local clients.** Serving files from the harness itself — same-origin with `/api`, behind `CSP: sandbox`, or from a second listener whose own port gives served documents their own origin — was rejected with the product scope: previews for a browser that is not on the Host machine are not supported, so the Host opener answers the supported case completely and the HTTP machinery would answer only the unsupported one. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 6e8806ab41..f463de969b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -10,7 +10,7 @@ Status: implemented 一个产出了文件的 web 会话,没有办法看到那个文件。agent(智能体)写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 -这背后是两个不同的缺陷。transcript(文本记录)从不说明一个轮次产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的「做完了打不开」,是一个可发现性失败叠在一项本就可用的能力之上。 +这背后是两个不同的缺陷。transcript(文本记录)从不说明一个轮次产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `session/openWorkspacePath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的「做完了打不开」,是一个可发现性失败叠在一项本就可用的能力之上。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 -**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。存在隐藏文件时,**在文件夹中显示**会把 `.` 经由同一 owner `openFile` 传递;它只在 loopback 页面的当前 `host.describe.canOpenPath` 允许原生打开时出现。其他部署会省略它;桌面探测误报时可配置 `nativeOpen: false`。 +**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `session/openWorkspacePath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。存在隐藏文件时,**在文件夹中显示**会把 `.` 经由同一 owner `openFile` 传递;它只在 loopback 页面的当前 `host.describe.canOpenPath` 允许原生打开时出现。其他部署会省略它;桌面探测误报时可配置 `nativeOpen: false`。 **以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 由 harness 自己提供文件——与 `/api` 同源、置于 `CSP: sandbox` 之后、或交给一个以自身端口给所服务文档独立源的第二监听器——随产品范围一并否决:不为「浏览器不在 Host 机器上」的场景提供预览,因此 Host 打开器完整回答受支持的场景,而那套 HTTP 机制只会回答不受支持的那个。 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index 477a354207..b959794f2d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.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 .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md -2026-08-05-durable-web-schedule.md: dbe42a3ac19642f0f66ee0819b9f6a6c8f298a5d -2026-08-05-durable-web-schedule.zh.md: 7f1d312058e400b0c1a32a28dc504a8866eea216 +2026-08-05-durable-web-schedule.md: 2a07d8257df6e940b316749a9b96a13abaf201dc +2026-08-05-durable-web-schedule.zh.md: c3a37f69ab74e5ededb7ca89c45ad9acfd029251 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index dbe42a3ac1..2a07d8257d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -60,7 +60,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing ### Read-only Web catalog -The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md); the [read-only catalog decision](2026-08-25-read-only-web-schedule-catalog.md) owns both presentation surfaces. This projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. +The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 7f1d312058..c3a37f69ab 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -60,7 +60,7 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒 ### 只读 Web 目录 -Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md);[只读目录决策](2026-08-25-read-only-web-schedule-catalog.zh.md)拥有这两个呈现面。该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。 +Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。 ## 已考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml index 0122488dac..29480a8bfb 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.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 .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md -2026-08-05-per-agent-tool-presentation.md: 1883a38f8541a524b9bf02628af7db3b015742d2 -2026-08-05-per-agent-tool-presentation.zh.md: fab41b81987f9b02b04f7260ed04893a59121e0c +2026-08-05-per-agent-tool-presentation.md: b85af656040ef365bb6ee6bfcd3d47686180d4d4 +2026-08-05-per-agent-tool-presentation.zh.md: e6d81ae072df905fddaa655da06abbb4f1ab73e6 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md index 1883a38f85..b85af65604 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -1,4 +1,4 @@ -# Agent Note: Per-agent tool presentation, and the `code` preset +# Agent Note: Per-agent tool presentation, and the `ptc` preset Status: implemented @@ -6,7 +6,7 @@ English | [中文](2026-08-05-per-agent-tool-presentation.zh.md) ## Problem -Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. +Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. PTC mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in PTC mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. The naive reading of "move tools down to the agent plane" does not work. `ctx.tools` has host-plane consumers that cannot follow it: `dsh-agent-loop` reads the registry's private scheduler seam, `dsh-apiproxy` reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is. @@ -14,16 +14,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there. -`ToolRuntime.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-presentation` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. +`ToolRuntime.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `ptc` preset carries the `tool-presentation` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. Two consequences fell out and are load-bearing: - **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. -- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. +- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a PTC mode was configured. Any agent may now select a PTC mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. -The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. +The SDK prompt section is registered globally by a ptc deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a ptc deployment free of an SDK section. -The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-presentation`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. +The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-presentation`, whose whole body is a `presentAs` call. A PTC mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting PTC mode against a runtime-less deployment fails where an operator can act. ## Alternatives considered @@ -41,6 +41,6 @@ The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-pr Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's. -`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `code` preset. +`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `ptc` preset. -The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not. +The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no ptc preset; the shipped Web overlay carries one, the base composition does not. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md index fab41b8198..e6d81ae072 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 按 agent 的工具呈现方式,以及 `code` 预设 +# Agent Note: 按 agent 的工具呈现方式,以及 `ptc` 预设 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。Code Mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 Code Mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 +agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。PTC mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 PTC mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 「把 tools 下沉到 agent 平面」这个字面读法行不通。`ctx.tools` 有一批跟不下来的宿主平面消费者:`dsh-agent-loop` 读它私有的调度器 seam,`dsh-apiproxy` 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有**所有**消费者一起下沉,服务才能下沉——注册表必须留在原地。 @@ -14,16 +14,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些 把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。 -`ToolRuntime.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-presentation` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 +`ToolRuntime.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`ptc` preset 携带 `tool-presentation` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 有两个随之而来的结果,且都是承重的: - **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 -- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 +- **保留名现在无条件生效。** `run_code` 此前只在配置了 PTC 模式时才被拒绝注册。如今任何 agent 都可能选择 PTC 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 -SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 +SDK 提示词段由 PTC 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 PTC 模式部署下选择退出」的 agent 不带 SDK 段。 -preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-presentation`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 +preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-presentation`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 PTC mode 的 preset,会在操作者能够动手的地方失败。 ## 考虑过的替代方案 @@ -41,6 +41,6 @@ preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-presentatio 同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息,现在引用的都是该 scope 的,而不是服务的。 -`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `code` 预设同时断言了这个区分的两侧。 +`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `ptc` 预设同时断言了这个区分的两侧。 -随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 +随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 PTC 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index 7a0f30b2e1..72b1b1301e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.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 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: b1cd1fb19317ddb96a15f4138c56fafc69cc9967 -2026-08-06-continuable-subagent-interrupt.zh.md: 40197f6b03f39910a6319f28f8f5aa8faac06ed7 +2026-08-06-continuable-subagent-interrupt.md: dfc8df2b0e32defa97b1596bc8d95d8b34d8f65e +2026-08-06-continuable-subagent-interrupt.zh.md: 3ac2ed34e50165a8958e4776e11f9cb863847276 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index b1cd1fb193..dfc8df2b0e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -45,4 +45,4 @@ The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` ## Testing -Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold real child turns open with replay hang entries and prove the parent-offline UI-to-RPC abort path, queued Send, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshot executes `list_agents({ scope: 'descendants' })` and `interrupt_agent` through the assembled application against one settled child, while recorded request headers continue to pin both schemas. +Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/api/gateway/tests/` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold real child turns open with replay hang entries and prove the parent-offline UI-to-RPC abort path, queued Send, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshot executes `list_agents({ scope: 'descendants' })` and `interrupt_agent` through the assembled application against one settled child, while recorded request headers continue to pin both schemas. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index 40197f6b03..3ac2ed34e5 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -45,4 +45,4 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 ## 测试 -`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)通过多条 replay hang 条目保持多个真实 child 轮次打开,端到端证明 parent 离线时从 UI 到 RPC 的中止路径、Send 入队、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照通过组装应用,针对一个已结算的 child 执行 `list_agents({ scope: 'descendants' })` 与 `interrupt_agent`,同时已录制的请求 header 仍固定这两个 schema。 +`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/api/gateway/tests/` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)通过多条 replay hang 条目保持多个真实 child 轮次打开,端到端证明 parent 离线时从 UI 到 RPC 的中止路径、Send 入队、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照通过组装应用,针对一个已结算的 child 执行 `list_agents({ scope: 'descendants' })` 与 `interrupt_agent`,同时已录制的请求 header 仍固定这两个 schema。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 37ebb517b5..c8be4143b4 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.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 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: 5ed9aff020f70c7f37452d903cd458697504bf97 -2026-08-07-default-model-follows-the-picker.zh.md: 1a5d025d41a853f8a662a58fc7dc99bbbfb68c49 +2026-08-07-default-model-follows-the-picker.md: b2d9c0fe58d9219fb33f749563d3d1fb10a20a8a +2026-08-07-default-model-follows-the-picker.zh.md: 7d3e5bc924f20e47b4d6a5e609c0e3c984ed6921 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index 5ed9aff020..b2d9c0fe58 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -12,7 +12,7 @@ Reasoning effort makes the persistence shape significant: a model selection with ## Decision -`AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). +`AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and Session Controller Remote creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. @@ -26,7 +26,7 @@ The stored selection does not require catalog membership. A provider route may s ## Consequences -`host.describe` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The gateway does not expose that namespace through its Settings-page allowlist; the model picker is its editor. +`session/modelCatalog` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The Settings page does not expose that namespace; the model picker is its editor. ## A session that cannot send diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 1a5d025d41..7d3e5bc924 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md))。 +`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 Session Controller Remote 创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md))。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;因此,缺少该字段会清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 @@ -26,7 +26,7 @@ Status: implemented ## 影响 -`host.describe` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。网关不通过 Settings 页 allowlist 暴露该 namespace;模型选择器是它的编辑器。 +`session/modelCatalog` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。Settings 页面不暴露该 namespace;模型选择器是它的编辑器。 ## 无法发送消息的会话 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 264571616c..9740b305f9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 6e70c67f771afeec881bdd8ad5c5322cc9190dfe -2026-08-08-user-explicit-skill-invocation.zh.md: 00c76ab99a2074b5d7d84c870afd9a9190ec45df +2026-08-08-user-explicit-skill-invocation.md: 87f4d04fb4bc34cba2ecb5e362d9a86270ab5195 +2026-08-08-user-explicit-skill-invocation.zh.md: 7b1aa18808054edc38b6801e028de1f0f0605d4a diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 6e70c67f77..87f4d04fb4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) ## Problem -A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skills/list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. ## Decision @@ -14,7 +14,7 @@ User-explicit invocation is a host-side pre-step injection, uniform for every us - `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `agent-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. - Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. -- The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skills/list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. - The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every entry point from implementing recognition. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index 00c76ab99a..7b1aa18808 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),输入的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skills/list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),输入的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 ## 决策 @@ -14,7 +14,7 @@ Status: implemented - `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `agent-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall(瀑布式事件)会把携带目录的列表交给它来扩展。 - 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 -- 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 +- 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skills/list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 - 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,由注入和 `skill` 工具结果共用,二者内容逐字相同,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种运行入口免于自行实现识别。 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index b0c70c4df4..4c276c6b0d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-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 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: 19306a35fe709a04d94090a62056575b4d51f7bc -2026-08-10-minimal-read-image-tool.zh.md: c7562c433e909d1f81361c0ced56318795e6469e +2026-08-10-minimal-read-image-tool.md: 8880032b2648846df679ea8fa3301d182a95c06b +2026-08-10-minimal-read-image-tool.zh.md: aec34e19fc58037b031f7d4116d2fa664b2b45b5 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index 19306a35fe..8880032b26 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -15,7 +15,7 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged - **`read_image` reads a filesystem path.** Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`. The tool result contains metadata and an `ImageBlock`. - **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). - **Registration is composition-conditional, execution is route-gated.** The tools register only under `ctx.inject(['attachments'], …)`. Before I/O, the strict gate resolves the calling route through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A text-only route can still consume prior durable images because the shared LLM runtime projects them to placeholders at request assembly. -- **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. +- **PTC mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. - **llm-replay models may declare `inputModalities`**, which lets keyless ACP snapshots cover the image-capable result and the text-only refusal. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index c7562c433e..aec34e19fc 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -15,7 +15,7 @@ Status: implemented - **`read_image` 读取文件系统路径。** 扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型,附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动。工具结果包含元数据和一个 `ImageBlock`。 - **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 - **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册。执行时在 I/O 之前通过 `ctx.llm.resolveModelInfo` 解析调用路由,并要求 `inputModalities` 包含 `image`;能力未知即拒绝。纯文本路由仍可使用此前的持久图片,因为共享 LLM 运行时会在请求组装时把图片投影为占位符。 -- **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 +- **PTC mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 - **llm-replay 模型可以声明 `inputModalities`**,因此 keyless ACP 快照可以覆盖支持图片的结果和纯文本拒绝。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 1132da788f..823b1327b0 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 3f56817c9c23ec55f2173b66fa915ab05646b2a7 -2026-08-10-telemetry-default-off.zh.md: ea89ea94dfea3106886e555e172ad8a6ab39305b +2026-08-10-telemetry-default-off.md: 1bc9719f7036509f1fff8c28f0607669505280ad +2026-08-10-telemetry-default-off.zh.md: 8f8bbb57810125059c177dc1c94f7631040a1d58 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 3f56817c9c..1bc9719f70 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -10,11 +10,11 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the ## Decision -Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. +Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. The shared base's session-backend default was later superseded by the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md), which resolves an unset `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY`; the hard opt-out and the launcher rule below remain current. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). -The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload. +The [CLI reference README](../../../../apps/cli/reference/README.md) documents the current deployment stance: the shared base defaults to feedback-gated sharing ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)), `DSH_TELEMETRY_MODE=FULL` and `DSH_TELEMETRY_MODE=DISABLED` are the explicit overrides, and enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload. ## Alternatives considered @@ -28,4 +28,4 @@ The [CLI reference README](../../../../apps/cli/reference/README.md) documents t ## Consequences -Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. +Fresh profiles and projects make no telemetry network request until the user records `/feedback` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)). `FULL` still requires an explicit setting; the launcher feed it once also enabled was deleted with the SDK project toolchain. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index ea89ea94df..8f8bbb5781 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -10,11 +10,11 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 ## 决策 -两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。 +两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。共享基础配置中会话后端的默认值后来被[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)取代,未设置的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY`;硬性退出开关和下文的启动器规则仍然有效。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.zh.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)删除之前,仅取代了启动器默认允许上报的规则。 -[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。 +[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了当前的部署口径:共享基础配置默认按反馈门控共享([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)),`DSH_TELEMETRY_MODE=FULL` 和 `DSH_TELEMETRY_MODE=DISABLED` 是显式覆盖值,开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。 ## 考虑过的替代方案 @@ -28,4 +28,4 @@ dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cord ## 后果 -全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 +全新 profile 和项目在用户记录 `/feedback` 之前不发出任何遥测网络请求([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md))。`FULL` 仍需显式设置;它曾一并启用的启动器数据流已随 SDK 项目工具链删除。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 3d120f1027..c093041c15 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: e64de4ecd564bf585ec857546913828a87ad1279 -2026-08-10-web-session-log-export.zh.md: 2ef377b4bb3c98f1935e67a1619bbcc5ed1789ef +2026-08-10-web-session-log-export.md: 69cc3d9fdfb267242de863c8359994ef25754bb1 +2026-08-10-web-session-log-export.zh.md: 9ab7318a4b89ddbd343739dc730569f4d8f584ba diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index e64de4ecd5..69cc3d9fdf 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. -- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`. - **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md). @@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences - Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. -- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 2ef377b4bb..9ab7318a4b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。 - **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。 @@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 - 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 -- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml new file mode 100644 index 0000000000..81672750b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.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-08-14-web-turn-process-folding.md +2026-08-14-web-turn-process-folding.md: fd0015dc915a296725344cd3f4ae05124089adf6 +2026-08-14-web-turn-process-folding.zh.md: 47b1a36bd75695a2f04dfed654d45ec81d2b8bc9 diff --git a/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md new file mode 100644 index 0000000000..fd0015dc91 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md @@ -0,0 +1,43 @@ +# Agent Note: Web Turn process folding + +Status: implemented + +English | [中文](2026-08-14-web-turn-process-folding.zh.md) + +## Problem + +A model Turn can expose System prompt, Context injection, reasoning, several Assistant replies, Tool calls, and Retry rows before its final answer. Keeping that whole trajectory at full height obscures the answer, while moving independent Chat Nodes under a parent disclosure would remount stateful Tool renderers and disturb chronological evidence. The compact view must hide completed process work without hiding the only evidence available while a Turn is still thinking, using a Tool, retrying, or ending without an answer. + +## Decision + +The Host-backed `ui-chat.transcriptView` preference selects `normal` or `compact` and defaults to `compact`. Normal leaves every process row visible and renders no Turn-process control. Compact applies the disclosure rules below. Switching modes changes wrapper visibility without reparenting or unmounting Chat Node renderers, and the preference remains outside the Session log. + +In Compact mode, a Turn remains fully expanded while it is open. At `turn/end`, its latest Step becomes a final-answer boundary only when it contains user-facing Assistant reply content—non-blank text, an image, or an unknown visible block—and contains no Tool-call block. The answer remains visible. Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows before that boundary form one process disclosure. System prompt, User, and steering rows remain independent and never join the group. Completed, aborted, interrupted, failed, and max-token Turns use the same terminal projection; error, max-token, and turn-tail rows remain outside the group. A closed Turn with no final answer keeps all process evidence visible. + +The Turn-scoped `turn-process` Definition derives the first model or Tool evidence, the latest Step's finalized answer boundary, reply-bearing durable Assistant-message count before that answer, and Tool-call counts from log events plus Step Location data. Shipped subagent delegation names (`subagent` and `subagent_*`) increment the subagent count instead of the ordinary Tool-call count, so the categories never overlap. Context injection remains process evidence without incrementing a summary count; System prompt is independent, stays visible, and remains before the opening User. It publishes an encoded scalar so unchanged facts retain value identity during streaming and contributes one stable control Chat Node. The Chat target positions opening User or steering input before process candidates from their first projection, then inserts the synthetic control between that input and the process rows. Without opening human input, the control stays before the earliest process candidate from its first appearance. Answer finalization, Retry, later Steps, completion, and manual expansion therefore change visibility without changing existing nodes' relative order, as specified by [stable Turn-process ordering](../bug-fix/2026-08-26-stable-turn-process-order.md). Each process member classifies itself from the Turn specification. Only the control and answer Seats subscribe to their Turn's content-revisioned Location key list and derive external process evidence plus independent-input spacing from that bounded list, so data-only updates refresh layout without rebuilding the global order. Turn status and loaded-window completeness then gate foldability: an open Turn never folds, and a partial history exposes neither the control nor hidden members. The control Node exists from the first process evidence onward but its Seat remains hidden until the closed Turn has a final answer and complete history. Once visible, it omits every zero-valued segment, uses `Thought for a while` when all three counts are zero, and places a full-width divider below the summary. + +The Chat target binds the durable transcript preference through the shared settings scope and keeps per-Turn interaction state in its session-scoped Chat store. `ChatView` renders each business Node through one stable keyed `ChatNodeSeat`; adding the process control does not reorder existing keys, and Compact mode changes the Seat wrapper's `hidden` attribute without reparenting or unmounting a Tool, Assistant, Context, or Retry renderer. The Seat passes the same process state through `ChatNodeOwnerProps`, so the final Assistant renderer hides reasoning blocks from its own Step while leaving reply blocks visible; wrapper visibility and inline reasoning therefore share one UI-state source. + +Closed process members use `hidden="until-found"`. A `beforematch` event on any member opens the shared group in supporting browsers. The Chat column applies spacing only between visible siblings because hidden-until-found members retain searchable zero-height boxes; the control's divider spans the content width, and a closed process control uses an 8px answer gap only when no independent input intervenes, while expansion restores the ordinary 16px row spacing. In Compact mode, the non-persisted session store contains only manually expanded Turn-and-answer-Step generations; absence means collapsed, and a different answer generation starts collapsed. Every eligible closed Turn therefore uses the same default regardless of whether it completed live, appeared after Load earlier, or closed while the reader was away from the tail. This can reflow content above the reader when a Turn closes or history becomes complete. An automatic collapse that would hide a focused process descendant opens the shared group instead, leaving keyboard focus in place; a manual close focuses the process control before hiding its members. If Load earlier is present, every process remains expanded and its control stays hidden; once history is complete, eligible groups immediately use the collapsed default. A fresh page load restores the durable Normal or Compact preference; per-Turn manual expansion survives only view remounts within the same page lifetime. Switching to Normal reveals every process row, while switching back to Compact reapplies the page-lifetime manual overrides over the collapsed default. + +This presentation composes with [Conversation Node assembly](../architecture/2026-08-09-client-conversation-node-assembly.md): Definitions own deterministic process facts, the Seat owns shared interaction state, and keyed renderers remain independent. The [log-ordered human transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.md) remains complete because folding changes no session event or model input. + +## Alternatives considered + +**Fold only earlier Assistant replies.** Rejected because a common `Think → Tool → answer` Turn has only one reply-bearing Step and would expose no compact control, leaving the user's requested process content at full height. + +**Keep Context injection outside the process.** Rejected because injected runtime context is part of the pre-answer trajectory rather than a new human instruction. Its own disclosure and label remain intact when the process is expanded. System prompt is kept outside because moving or hiding the request-wide instruction changes the visible frame around the opening User. + +**Reparent the whole Turn under one summary row.** Rejected because eligibility changes while the Turn runs, and moving existing Chat Nodes across React parents remounts stateful Tool views. Stable Seats provide one disclosure without moving their children. + +**Store manual expansion in Turn Location data.** Rejected because Location data is a deterministic projection of session events and has no browser-action write path. UI gestures belong to a declared, non-persisted store. + +**Reuse `DisclosureRow` and unmount closed members.** Rejected because browser find could not discover their text and reopening would reconstruct stateful renderer subtrees. + +**Fold a live answer candidate before `turn/end`.** Rejected because streamed text can still be followed by a Tool call, Retry, or later thinking Step. Waiting for the terminal boundary prevents automatic collapse–expand–collapse cycles and the resulting layout jumps. + +**Defer a newly eligible collapse while the reader is away from the tail.** Rejected because it requires transient completion tracking and deferred state, and makes identical closed Turns start in different states depending on how they entered the viewport. Closed Turns use one deterministic default; scroll anchoring preserves position, while the focus guard preserves an active interaction. + +## Consequences + +Compact mode keeps the final answer prominent even when the Turn contains only injected Context, reasoning, or Tools before it, while expansion restores every process row in original order. Normal mode preserves the complete transcript without Turn-level controls. Hidden wrappers and Markdown subtrees remain mounted, trading browser memory for stable Tool state, manual expansion across view remounts, and browser-find recovery. Ordinary cross-message selection excludes closed members only in Compact mode; users expand the group before selecting them or choose Normal. Browsers without `hidden="until-found"` and `beforematch` retain manual disclosure but cannot reveal closed process text through page search. Unit coverage pins finalized answer boundaries, Retry and interruption, shared cross-kind expansion, final-Step reasoning, mode switching, manual expansion, immediate history-completion folding, off-tail folding, focus preservation, and content-only final-page revisions; assembled browser snapshots pin running, aborted, completed, paged-history, and persisted-setting trajectories. diff --git a/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md new file mode 100644 index 0000000000..47b1a36bd7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Web 轮次过程折叠 + +Status: implemented + +[English](2026-08-14-web-turn-process-folding.md) | 中文 + +## 问题 + +一个模型轮次可能会在最终正文之前展示系统提示词、上下文注入、推理、多条 Assistant 回复、工具调用与重试行。完整展示整条轨迹会淹没正文,而把独立 Chat Node 移入一个父级 disclosure 会重挂载有状态的工具 renderer,并扰乱按时间排列的证据。紧凑视图必须收起已完成的过程工作,同时在轮次仍处于推理、使用工具、重试或没有正文便结束时,保留当下唯一可用的证据。 + +## 决策 + +由 Host 支撑的 `ui-chat.transcriptView` 偏好提供 `normal` 与 `compact` 两种模式,默认值为 `compact`。Normal 保持所有过程行可见且不渲染轮次过程控件;Compact 应用下述 disclosure 规则。切换模式只改变 wrapper 可见性,不会重新挂接或卸载 Chat Node renderer;该偏好不写入 Session log。 + +在 Compact 模式下,轮次打开期间始终完整展开。到 `turn/end` 时,只有当最近步骤包含面向用户的 Assistant 回复内容——非空文本、图片或未知可见块——并且不含工具调用块时,该步骤才成为最终正文边界。正文保持可见。边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行统一进入一条过程 disclosure。系统提示词、用户消息与 steering 消息保持独立,绝不加入过程组。已完成、已取消、已中断、失败和达到最大 token 的轮次使用同一终态投影;错误、最大 token 与 turn-tail 行留在过程组外。关闭时没有最终正文的轮次会保留全部过程证据。 + +轮次作用域的 `turn-process` Definition 根据日志事件与步骤 Location data 推导首条模型或工具证据、最近步骤的已定稿正文边界、该正文之前带回复内容的持久 Assistant 消息数和工具调用计数。随产品交付的 subagent 委派名称(`subagent` 与 `subagent_*`)只增加 subagent 计数,不增加普通工具调用计数,因此两类不会重叠。上下文注入仍是过程证据但不增加摘要计数;系统提示词保持独立、持续可见,并始终位于开场 User 上方。它发布编码后的标量,使未变化的事实在流式期间保持值相等,并贡献一个稳定控制 Chat Node。Chat target 从首次投影起就把开场 User 或 steering 输入放在过程候选之前,再把合成控制行插入该输入与过程行之间。没有开场人工输入时,控制行从首次出现起就位于最早的过程候选之前。因此正文定稿、Retry、后续步骤、完成状态与手动展开只改变可见性,不改变既有节点的相对顺序,具体规则由[稳定的轮次过程排序](../bug-fix/2026-08-26-stable-turn-process-order.zh.md)说明。每个过程成员根据轮次规格判断自身归属。只有控制 Seat 与正文 Seat 订阅所属轮次在内容变化时更新的 Location key 列表,并在这份受限列表内推导外部过程证据和独立输入间距,因此纯数据更新会刷新布局,而不必重建全局顺序。轮次状态与已加载窗口是否完整随后共同决定能否折叠:打开中的轮次绝不折叠,历史不完整时也既不显示控件又不隐藏成员。控制 Node 从首条过程证据出现起一直存在,但其 Seat 会保持隐藏,直至关闭的轮次拥有最终正文且历史完整;显示后,它会省略每个值为 0 的分段,三项全为 0 时使用「已思考」(英文为 `Thought for a while`),并在摘要下方绘制通栏分隔线。 + +Chat target 通过共享 settings scope 绑定持久化的 transcript 偏好,并把逐轮交互状态保存在会话作用域的 Chat store 中。`ChatView` 通过稳定的 keyed `ChatNodeSeat` 直接渲染每个业务 Node;加入过程控件不会重排既有 key,Compact 模式只改变 Seat wrapper 的 `hidden` 属性,不会重新挂接或卸载工具、Assistant、上下文或重试 renderer。Seat 通过 `ChatNodeOwnerProps` 传递同一份过程状态,因此最终 Assistant renderer 会隐藏自身步骤中的推理块,同时保留回复块;wrapper 可见性与行内推理共用一个 UI 状态真源。 + +收起的过程成员使用 `hidden="until-found"`。在支持该能力的浏览器中,任一成员触发 `beforematch` 都会打开共享过程组。由于 hidden-until-found 成员会保留可搜索的零高度 box,Chat 列只在可见的相邻成员之间设置间距;控件分隔线横跨内容宽度,只有中间没有独立输入时,收起的过程控件才与正文相隔 8px,展开后恢复普通的 16px 行间距。在 Compact 模式下,不持久化的会话 store 只保存用户手动展开的「轮次 + 正文步骤」generation;没有记录即为收起,不同正文 generation 默认收起。因此,每个合格的已关闭轮次都使用相同默认状态,不区分实时完成、在「加载更早」后出现,或在读者离开尾部时结束。这可能在轮次关闭或历史变完整时让读者上方的内容重排。若自动收起会隐藏过程成员中的键盘焦点,则改为打开共享过程组并把焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。存在「加载更早」时,每个过程保持展开且控件隐藏;历史加载完整后,合格过程立即使用默认收起状态。页面重新加载会恢复持久化的 Normal 或 Compact 偏好;逐轮手动展开只在同一页面生命周期内的 view remount 之间保留。切换到 Normal 会显示所有过程行,切回 Compact 时会在默认收起状态上重新应用当前页面生命周期内的手动展开记录。 + +这项展示与 [Conversation Node 组装](../architecture/2026-08-09-client-conversation-node-assembly.zh.md)共同成立:Definition 持有确定性的过程事实,Seat 持有共享交互状态,keyed renderer 保持独立。[按日志顺序投影的人工 transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md)保持完整,因为折叠不改变任何会话事件或模型输入。 + +## 曾考虑的替代方案 + +**只折叠较早的 Assistant 回复。** 不采用:常见的 `Think → Tool → 正文` 轮次只有一个回复步骤,不会出现紧凑控件,用户要求收起的过程内容仍会完整展开。 + +**把上下文注入留在过程组外。** 不采用:注入的运行时上下文属于正文之前的轨迹,并不是一条新的人类指令;展开过程后,其自身的 disclosure 与标签仍完整保留。系统提示词则留在过程组外,因为移动或隐藏整次请求使用的指令会改变开场 User 周围的可见框架。 + +**把整个轮次重新挂接到一条摘要行下。** 不采用:折叠资格会在轮次运行期间变化,而把既有 Chat Node 移过 React 父节点会重挂载有状态工具视图。稳定 Seat 可以提供一个 disclosure,同时不移动其子节点。 + +**把手动展开状态存入轮次 Location data。** 不采用:Location data 是会话事件的确定性投影,不存在浏览器动作写入路径。UI 手势属于已声明且不持久化的 store。 + +**复用 `DisclosureRow` 并卸载收起成员。** 不采用:浏览器查找无法发现其文本,重新打开也会重建有状态 renderer 子树。 + +**在 `turn/end` 前折叠实时正文候选。** 不采用:流式文本后仍可能出现工具调用、重试或后续仅推理步骤。等待终态边界可以避免自动收起—展开—再次收起及其造成的布局跳动。 + +**读者离开尾部时暂缓刚获得资格的收起。** 不采用:该方案需要瞬时完成检测与 deferred 状态,并使相同的已关闭轮次根据进入视口的路径获得不同初始状态。已关闭轮次统一使用确定性的默认值;滚动锚定负责保持位置,焦点保护负责保留正在进行的交互。 + +## 后果 + +Compact 模式会在轮次正文前只有注入上下文、推理或工具时仍突出最终正文,展开后每条过程行按原顺序恢复;Normal 模式则保留完整 transcript 且不展示轮次过程控件。隐藏的 wrapper 与 Markdown 子树保持挂载,用一定浏览器内存换取稳定工具状态、跨视图重挂载的手动展开状态与浏览器查找恢复。只有 Compact 模式下的普通跨消息选择会排除仍收起的成员;用户可以先展开过程组,或切换到 Normal。不支持 `hidden="until-found"` 与 `beforematch` 的浏览器仍可手动展开,却无法通过页内查找揭示收起的过程文本。单元覆盖固定已定稿正文边界、重试与中断、跨 kind 共享展开、最终步骤推理、模式切换、手动展开、历史补全后立即收起、离尾收起、焦点保留与最终分页的纯数据更新;组装后的浏览器快照固定运行中、已取消、已完成、分页历史与持久化设置轨迹。 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 39403b4062..5e0f82335f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c -2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7 +2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086 +2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index bf4788b141..9cdbdbec3b 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -12,15 +12,15 @@ The model also needs a bounded way to discover live providers and model-owned ef ## Decision -`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its Agent-scoped `modelSelectionSettings` instance resolves a Session policy and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. The policy uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md); there is no unrestricted static mode. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. A settings-enabled instance against a provider without the capability fails the plugin mount. Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists authorized registered providers; with `provider` it calls that adapter's advisory model catalog only after authorization; with `provider` and `model` it authorizes the exact route before resolving its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-off Host-owned `subagent-model-selection` setting with an explicit `enabled` switch and `allowedModels` list. The Plugins settings page stores both fields atomically. A new top-level Session snapshots the route policy as `subagent/model-selection-policy` when the setting is enabled, before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. Discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. -Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. +Shipped `subagent_fork` instances do not read model-selection settings even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. @@ -28,7 +28,7 @@ The delegation definition is static across adapter registration and catalog chan ## Alternatives considered -**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register. +**Require a deployment-configured route allowlist for static enablement.** Rejected because it duplicates the live LLM registry and requires configuration before a custom composition can use an already registered route. The shipped user-owned preference is a distinct authorization decision and is documented by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). **Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema. @@ -48,8 +48,8 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences -- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. -- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation. +- A settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields. +- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 1d83e2e91f..3b81f54173 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决策 -只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 +只有 Agent 作用域的 `modelSelectionSettings` 实例解析出 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。该策略使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权;不存在无限制静态模式。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果 settings 已启用的实例缺少该提供方能力,插件挂载会失败。 提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已授权且已注册的提供方;提供 `provider` 时先授权,再调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时会先授权精确路由,再解析其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册 Host 自有的 `subagent-model-selection` 设置,其中包含默认关闭的显式 `enabled` 开关与 `allowedModels` 列表。Plugins 设置页会原子保存两个字段。设置启用时,新的顶层 Session 会在任何模型请求之前,把路由策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 -随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 +随附的 `subagent_fork` 实例不会读取模型选择设置,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 @@ -28,7 +28,7 @@ Status: implemented ## 考虑过的替代方案 -**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。 +**要求静态启用配置部署路由允许列表。** 不采用,因为它会重复实时 LLM 注册表,并要求自定义组合先配置才能使用已经注册的路由。随附的用户自有偏好属于另一项授权决定,由[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)记录。 **在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。 @@ -48,8 +48,8 @@ Status: implemented ## 结果 -- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 -- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。 +- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml new file mode 100644 index 0000000000..2a8c4bbcb5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-08-24-user-authorized-subagent-model-routes.md +2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md new file mode 100644 index 0000000000..fce0026f65 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -0,0 +1,43 @@ +# Agent Note: User-authorized subagent model routes + +Status: implemented + +English | [中文](2026-08-24-user-authorized-subagent-model-routes.zh.md) + +## Problem + +Registering an LLM adapter makes its routes reachable, but does not authorize an Agent to choose every reachable model for a child. A single enabled preference over the live adapter registry expands silently when another provider or model appears. The product needs an explicit, stable authorization decision without rendering a potentially large model directory into every parent request. + +## Decision + +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. + +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. + +The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. + +Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. + +## Alternatives considered + +**Render the allowed routes in the delegation description.** Rejected because a large or changing list would enlarge every request and invalidate an early prompt prefix. On-demand discovery keeps the fixed schema prefix-stable and logs directory content only when requested. + +**Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child. + +**Infer enablement from a non-empty `allowedModels` array.** Rejected because disabling would have to discard a useful selection or preserve a non-empty array whose meaning depends on write history. The explicit switch is authoritative, and the settings scope submits both fields in one Host-validated mutation so no intermediate state is persisted. + +**Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized. + +**Read current settings on every discovery or delegation call.** Rejected because a settings edit would silently change a running Session's model-visible capabilities and execution authority. The durable Session snapshot keeps resume and child inheritance deterministic. + +## Consequences + +- New adapter registrations and newly advertised models do not expand user authorization. +- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. +- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. +- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. + +## Related decisions + +The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md). diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md new file mode 100644 index 0000000000..2cdf42dd33 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 用户授权的 subagent 模型路由 + +Status: implemented + +[English](2026-08-24-user-authorized-subagent-model-routes.md) | 中文 + +## Problem + +注册 LLM 适配器会使其路由可达,但不代表授权 Agent 为子级选择每一个可达模型。针对实时适配器注册表的单一启用偏好,会在另一个提供方或模型出现时静默扩大范围。产品需要一项显式且稳定的授权决定,同时避免把可能很大的模型目录渲染进父 Agent 的每次请求。 + +## Decision + +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 + +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 + +固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 + +模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 + +## Alternatives considered + +**在委派描述中渲染允许路由。** 不采用,因为很大或变化的列表会扩大每次请求,并使较早的提示词前缀失效。按需发现会保持固定 schema 的前缀稳定,且只在请求目录时记录其内容。 + +**只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。 + +**从非空 `allowedModels` 数组推断是否启用。** 不采用,因为关闭功能时要么必须丢弃仍有用的选择,要么要保留一个含义取决于写入历史的非空数组。显式开关是权威依据,设置 scope 会在一次由 Host 校验的 mutation 中提交两个字段,因此不会持久化中间状态。 + +**保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。 + +**每次发现或委派调用都读取当前设置。** 不采用,因为设置编辑会静默改变运行中 Session 的模型可见能力和执行权限。持久 Session 快照会让恢复与子级继承保持确定。 + +## Consequences + +- 新适配器注册和新公布模型不会扩大用户授权。 +- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 +- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 +- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 + +## Related decisions + +路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)负责。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml index 7478d79c43..6221d9d868 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.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 .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md -2026-07-26-code-mode-live-parallel-dispatch.md: 998152ee43d93e5190062cb632e20a3123cda4f9 -2026-07-26-code-mode-live-parallel-dispatch.zh.md: e70e23a326e645749fe961d1d55a47fc5753ee75 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md +2026-08-25-feedback-gated-telemetry-default.md: 772d134da53386292083790148dce738a98f2c0f +2026-08-25-feedback-gated-telemetry-default.zh.md: ea05d4d687bc2270f907983a71f592b880d7449c diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md new file mode 100644 index 0000000000..772d134da5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md @@ -0,0 +1,29 @@ +# Agent Note: Feedback-gated session-telemetry default + +Status: implemented + +English | [中文](2026-08-25-feedback-gated-telemetry-default.zh.md) + +## Problem + +Diagnosing a `/feedback` report needs the session data the report describes. With the shared base resolving an unset `DSH_TELEMETRY_MODE` to `DISABLED`, a default installation's feedback reached its receiver with no session data at all, and the reporter had no way to grant access at the moment they asked for help; only deployments that had exported `DSH_TELEMETRY_MODE` beforehand ever delivered a diagnosable report. + +## Decision + +The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; each recorded feedback uploads the not-yet-shared session-log records — from the last handoff through that exact event — to the configured OTLP endpoint, a resumed session shares only its current lifecycle, and the acknowledgement's sharing disclosure states that recording feedback uploads the records not yet shared. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. + +This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. + +## Alternatives considered + +**Keep `DISABLED` and instruct reporters to re-run with `DSH_TELEMETRY_MODE=FEEDBACK_ONLY`.** Rejected: the session that exhibited the problem is the one worth uploading, and re-running loses it. + +**Default to `FULL`.** Rejected: continuous export without any user action is exactly what the default-off decision forbids, and nothing in a fresh installation authorizes it. + +**Gate the official DeepSeek `dsh_session_log` request contribution on feedback instead of reviving the OTel default.** Not taken here: that contribution uploads through subsequent LLM requests rather than at the feedback boundary, so a session's final feedback would never be delivered; a feedback-triggered flush on that path is a larger design than a default flip. + +## Consequences + +- A fresh installation uploads the not-yet-shared session-log records to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. +- Released exports remain the raw captured copy: the shipped base mounts no `session-telemetry/record` redaction rule, so they can contain message text, tool arguments and results, and workspace paths. +- The sharing disclosure is part of the `/feedback` acknowledgement, so the user reads it after the release has been triggered. A deployment that requires prior informed consent must override the default to `DISABLED` or add a pre-upload confirmation before this default is defensible there. diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md new file mode 100644 index 0000000000..ea05d4d687 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 反馈门控的会话遥测默认值 + +Status: implemented + +[English](2026-08-25-feedback-gated-telemetry-default.md) | 中文 + +## 问题 + +诊断一条 `/feedback` 报告需要报告所描述的会话数据。共享基础配置把未设置的 `DSH_TELEMETRY_MODE` 解析为 `DISABLED`,因此默认安装发出的反馈到达接收方时不带任何会话数据,报告者在求助的那一刻也没有授权共享的途径;只有事先导出了 `DSH_TELEMETRY_MODE` 的部署才能交付可诊断的报告。 + +## 决定 + +共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;每条已记录的反馈把尚未共享的会话日志记录——自上次交接至该事件为止——上传到已配置的 OTLP 端点,恢复的会话只共享当前生命周期,确认信息中的共享声明会说明记录反馈将上传尚未共享的记录。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 + +本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。 + +## 考虑过的替代方案 + +**保持 `DISABLED`,让报告者带着 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 重跑。** 否决:值得上传的正是出现问题的那个会话,重跑会丢掉它。 + +**默认 `FULL`。** 否决:没有任何用户动作的持续导出正是默认关闭决定所禁止的,全新安装中没有任何东西授权它。 + +**改为在反馈时门控官方 DeepSeek `dsh_session_log` 请求贡献,而不是恢复 OTel 默认值。** 此处未采用:该贡献通过后续 LLM 请求上传,而不是在反馈边界上传,会话的最后一条反馈永远不会被交付;在那条路径上做反馈触发的冲刷是比翻转默认值更大的设计。 + +## 后果 + +- 全新安装只在用户记录 `/feedback` 时把尚未共享的会话日志记录上传到生产 collector;没有其他触发上传的途径。 +- 释放的导出仍是未加工的原始副本:随附基础配置没有挂载 `session-telemetry/record` 脱敏规则,导出可能包含消息文本、工具参数和结果,以及 workspace 路径。 +- 共享声明是 `/feedback` 确认信息的一部分,用户读到它时释放已被触发。要求事先知情同意的部署必须把默认值覆盖为 `DISABLED`,或在上传前增加确认步骤,此默认值在那类部署中才站得住。 diff --git a/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.i18n.yaml deleted file mode 100644 index 82687c81f2..0000000000 --- a/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md -2026-08-25-read-only-web-schedule-catalog.md: dc5e6d0a208d4c5269707dd0a3c9774f0b1d188c -2026-08-25-read-only-web-schedule-catalog.zh.md: 567a652072727e0bdadac06bb030e4c196763b6a diff --git a/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md b/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md deleted file mode 100644 index dc5e6d0a20..0000000000 --- a/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: Read-only Web Schedule catalog - -Status: implemented - -English | [中文](2026-08-25-read-only-web-schedule-catalog.zh.md) - -## Problem - -Schedule already persisted active reminders and delivered due work as ordinary later conversation turns, but a person using Web could not inspect what remained active. The model-facing `schedule_list` tool was not a suitable browser contract: calling it would add a tool transaction, couple UI to Agent availability, and duplicate the Session projection transport already used for durable read models. - -The catalog also had to preserve two existing boundaries. A fork must not inherit the parent Session's active reminders even though its event array contains the inherited prefix, and an active-reminder list must not become a second delivery receipt beside the ordinary Assistant response. - -## Decision - -Schedule registers an optional `schedule` Session projection and a separate browser package renders that complete active value. The durable `schedule/change` stream remains the only authority; the browser performs presentation-only derivation and exposes no mutation. - -### Projection boundary - -The Schedule unit reuses the domain's strict transition and publishes the complete active `ScheduleRecord[]`; damaged authoritative input fails the existing read/open path, while the production prepared-session path can rebuild a malformed disposable checkpoint from the log. The shared [projection state and Client views decision](../architecture/2026-08-19-session-projection-state-and-client-views.md) owns `init(header)`, centralized seed-boundary validation, checkpoint validation, and the live/cache/history/detached drive paths. This note owns only how the resulting active value is presented in Web. - -`@deepseek-ai/dsh-schedule/client` is a type-only browser-safe export of the durable record vocabulary. It does not pull the Cordis plugin, runtime, timers, tools, or Node dependencies into the client graph. - -### Web composition and visibility - -The shipped Web bundle owns the `@deepseek-ai/dsh-client-ui-schedule` resolution dependency and one `ui-schedule` row with `disabled: true`. The existing Schedule overlay loads `time-context` and the Schedule Host plugin, then enables that row by id. Ordinary Web therefore resolves but never starts the plugin; only an explicit Schedule composition gets both halves. - -The header action reads `openState` through the standard Session hook and the `schedule` projection through `useProjection`. It renders only when `openState === 'open'` and the array is non-empty. This gate also hides a prewarmed listing-cache value when opening the current Session fails. A live update that removes the final record closes and unmounts the control. - -The slot entry uses internal order 10: static Agent and Subagent information precede it, while the Jobs entry at order 20 follows it. The component owns no shared store; popover visibility is its only local interaction state. - -### Sidebar marker - -`ui-workspace` owns the ordinary, flat, and search Session rows. It derives one display fact from `SessionSummary.projectionValues.schedule`: a non-empty array renders the same outline alarm after the title, before the ordinary row's update time. The icon is not separately clickable or tabbable; its localized tooltip and screen-reader label say that the Session has an active scheduled task. - -Cold rows intentionally inherit projection-cache semantics. An identity-matching usable cached value can show the alarm without opening the Session; a missing or stale cache may cause a brief omission or residue. The marker reports only an undispatched or undeleted durable record known to the list value. It never asserts that a Schedule runtime is live or can wake the Session. - -### Presentation and interaction - -The 336px popover renders one non-focusable row per active record. The prompt is complete plain text with wrapping and no line clamp; the list scrolls vertically when its content exceeds the existing maximum height. Rows contain no Schedule id, raw UTC, details, or controls. - -Each row presents status separately from three metadata fields. After and At are localized as Once. Every chooses the largest day, hour, minute, or second unit that divides the durable `everySeconds` value exactly, so 300 seconds becomes 5 minutes while 301 stays 301 seconds. The browser formats `scheduledAt` in its current locale and time zone and derives relative time from its current clock. These values are not written back to the projection. - -Rows sort overdue first, then by ascending `scheduledAt`, then by the projection array index. The final tie-break preserves the Schedule fold's creation order without adding a durable ordering field. Scheduled state uses the business-blue semantic dot; overdue uses the warning-amber semantic dot and row treatment. - -The trigger is the catalog's only tab stop. Native button behavior provides Enter and Space activation. Escape closes an open popover and returns focus to the trigger; a pointer press outside closes it. When a projection update removes the last row, the component does not call focus or move it to a neighboring header action. - -### Delivery boundary - -The catalog is current active state, not history or proof of delivery. A terminal delete or dispatch removes a row. Due work still enters the transcript only through the ordinary Schedule `followup()` and Assistant result. The catalog emits no message, card, toast, acknowledgement, retry affordance, or Schedule-specific error entry. - -## Alternatives considered - -**Call `schedule_list` from the browser.** This crosses the model-facing tool boundary, requires a live Agent, and creates request and stale-response machinery for data already available through the projection carrier. - -**Render raw `schedule/change` events.** Events are persistence protocol, not presentation. A client-side fold would duplicate strict domain logic and expose internal ids and transitions. - -**Persist status, relative time, or display order.** These values depend on the viewing browser's clock, locale, and time zone. Persisting them would make replay environment-dependent and introduce unnecessary durable fields. - -**Show the control while Session open is failing.** A cached list value may be older than a corrupt tail. Rendering it would present stale partial truth precisely when strict replay rejected the authoritative Session. - -**Add row actions or a receipt history.** Mutation belongs to the existing tools, while delivery history belongs to the ordinary transcript. Combining either with this catalog would change its authority and accessibility model. - -## Verification - -Focused projection and Schedule tests cover strict folding, fork-prefix exclusion, restore, corruption, and registration lifetime. `ui-schedule` tests cover the header catalog's open-state gate, localized formatting, clock-driven status and ordering, wrapping and scrolling, removal, pointer and keyboard behavior, and focus boundaries. `ui-workspace` tests cover grouped, flat, and search marker derivation, placement, localization, accessibility, and row-click behavior. One keyless shipped-Web smoke covers default-disabled versus overlay-enabled composition, a cached marker in ordinary and search rows, the current Session's 900px dark catalog, and one live empty update removing both header and sidebar indicators; the existing conversational scenario continues to cover ordinary Assistant delivery. - -## Consequences - -- A person can inspect every active reminder without invoking the model or adding another durable source of truth. -- Fork isolation belongs to the shared projection initialization contract rather than a Schedule-specific out-of-band scan. -- Sidebar alarms remain best-effort cache-backed list presentation and never become runtime-liveness indicators. -- Browser time labels may differ across viewers by locale, time zone, and clock while the durable records remain identical. -- Corrupt Schedule history fails the normal Session path and never degrades into a plausible-looking partial catalog. -- The catalog cannot acknowledge, retry, edit, or prove delivery; those semantics remain deliberately outside this surface. diff --git a/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md b/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md deleted file mode 100644 index 567a652072..0000000000 --- a/.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note:只读 Web Schedule 目录 - -Status: implemented - -[English](2026-08-25-read-only-web-schedule-catalog.md) | 中文 - -## 问题 - -Schedule 已经持久化活动提醒,并把到期工作作为普通后续对话轮次交付,但 Web 用户无法查看仍有哪些提醒处于活动状态。面向模型的 `schedule_list` 工具不适合作为浏览器契约:调用它会增加一次工具事务、把 UI 耦合到 Agent 可用性,并重复 Session projection transport 已经提供的持久读模型通道。 - -该目录还必须保留两条既有边界。fork 的事件数组虽然包含继承前缀,却不能继承父 Session 的活动提醒;活动提醒列表也不能在普通 Assistant 回答之外变成第二种交付回执。 - -## 决策 - -Schedule 注册一个可选的 `schedule` Session projection,由独立浏览器包渲染这份完整活动值。持久 `schedule/change` stream 仍是唯一权威;浏览器只做呈现派生,不公开 mutation。 - -### Projection 边界 - -Schedule 单元复用领域的严格 transition,并发布完整的活动 `ScheduleRecord[]`;损坏的权威输入会使既有读取/打开路径失败,生产 prepared-session 路径可以从日志重建畸形的可丢弃 checkpoint。共享的 [projection state 与 Client views 决策](../architecture/2026-08-19-session-projection-state-and-client-views.zh.md)拥有 `init(header)`、集中 seed 边界校验、checkpoint 校验,以及 live/cache/history/detached 驱动路径。本 Note 只拥有所得活动值在 Web 中的呈现方式。 - -`@deepseek-ai/dsh-schedule/client` 是持久记录词汇的纯类型浏览器安全出口。它不会把 Cordis 插件、runtime、timer、工具或 Node 依赖带入 client graph。 - -### Web 组合与可见性 - -shipped Web bundle 拥有 `@deepseek-ai/dsh-client-ui-schedule` 的解析依赖,以及一个带 `disabled: true` 的 `ui-schedule` row。现有 Schedule overlay 加载 `time-context` 与 Schedule Host 插件,再按 id 启用该 row。普通 Web 因而只解析但绝不启动该插件;只有显式 Schedule 组合同时获得 Host 与 client 两半。 - -header action 通过标准 Session hook 读取 `openState`,通过 `useProjection` 读取 `schedule` projection。只有 `openState === 'open'` 且数组非空时才渲染。这条门槛也会在当前 Session 打开失败时隐藏曾由列表缓存预热的值。live 更新移除最后一条记录时,控件会关闭并卸载。 - -slot 条目使用内部 order 10:静态 Agent 与 Subagent 信息位于它之前,order 20 的 Jobs 入口位于它之后。组件不拥有共享 store;popover 是否打开是唯一的本地交互状态。 - -### 侧边栏标识 - -`ui-workspace` 拥有普通、平铺与搜索 Session 行。它从 `SessionSummary.projectionValues.schedule` 派生一个展示事实:非空数组会在标题之后渲染同一枚轮廓闹钟,普通行的更新时间仍位于其后。图标不单独响应点击或进入 Tab 顺序;本地化 tooltip 与读屏标签说明该 Session 有活动定时任务。 - -cold 行有意继承 projection-cache 语义。身份匹配且可用的缓存值可以在不打开 Session 的情况下显示闹钟;cache 缺失或陈旧可能造成短暂漏显或残留。该标识只报告列表值已知存在尚未 dispatch 或 delete 的持久记录,绝不表示 Schedule runtime 当前 live 或能够唤醒该 Session。 - -### 呈现与交互 - -336px 弹层为每条活动记录渲染一行不可聚焦内容。prompt 是可完整换行、没有 line clamp 的纯文本;内容超过既有最大高度时,列表在内部纵向滚动。行中不包含 Schedule id、原始 UTC、详情或操作控件。 - -每行把状态与三项元数据分开呈现。After 与 At 本地化为「单次」。Every 选择能够整除持久 `everySeconds` 值的最大日、小时、分钟或秒单位,因此 300 秒显示为 5 分钟,301 秒仍显示为 301 秒。浏览器用当前 locale 与时区格式化 `scheduledAt`,并按当前时钟派生相对时间。这些值都不会写回 projection。 - -行先按 overdue 排序,再按 `scheduledAt` 升序,最后按 projection 数组索引排序。最终 tie-break 保留 Schedule fold 的创建顺序,不增加持久排序字段。scheduled 状态使用业务蓝语义圆点;overdue 使用警告琥珀语义圆点与行背景。 - -触发器是目录唯一的 Tab stop。原生 button 行为提供 Enter 与 Space 激活。Escape 会关闭已打开的弹层并把焦点还给触发器;在外部按下指针也会关闭。projection 更新移除最后一行时,组件不会调用 focus,也不会把焦点迁移到相邻 header action。 - -### 交付边界 - -该目录表示当前活动状态,不是历史或交付证明。终结性的 delete 或 dispatch 会移除一行。到期工作仍只通过普通 Schedule `followup()` 与 Assistant 结果进入 transcript。目录不发出消息、卡片、Toast、acknowledgement、Retry 控件或 Schedule 专属错误入口。 - -## 已考虑的替代方案 - -**从浏览器调用 `schedule_list`。** 这会跨越面向模型的工具边界,需要 live Agent,并为 projection carrier 已经拥有的数据制造请求与旧响应处理机制。 - -**渲染原始 `schedule/change` 事件。** 事件是持久化协议,不是呈现协议。客户端 fold 会重复严格领域逻辑,并暴露内部 id 与 transition。 - -**持久化状态、相对时间或显示顺序。** 这些值取决于查看方浏览器的时钟、locale 与时区。持久化它们会使回放依赖环境,并增加不必要的持久字段。 - -**在 Session 打开失败时仍显示控件。** 缓存的列表值可能旧于损坏的 tail。显示它会在严格回放已经拒绝权威 Session 时呈现貌似可信的部分事实。 - -**增加行操作或回执历史。** mutation 属于既有工具,交付历史属于普通 transcript。把任一项并入该目录都会改变它的权威与可访问性模型。 - -## 验证 - -聚焦 projection 与 Schedule 测试覆盖严格 fold、fork 前缀排除、restore、损坏传播与注册生命周期。`ui-schedule` 测试覆盖 header 目录的 open-state 门槛、本地化格式、由时钟驱动的状态与排序、换行与滚动、移除、pointer/键盘行为及焦点边界。`ui-workspace` 测试覆盖分组、平铺与搜索标识的派生、位置、本地化、无障碍与整行点击行为。一个无密钥 shipped-Web smoke 覆盖默认 disabled 与 overlay enabled 组合、普通行与搜索结果中的缓存标识、当前 Session 的 900px 暗色目录,以及一次 live empty 更新同时移除 header 与侧边栏标识;既有对话场景继续覆盖普通 Assistant 交付。 - -## 后果 - -- 用户可以查看每条活动提醒,而无需调用模型或增加另一份持久权威。 -- fork 隔离属于共享 projection 初始化约定,而不是 Schedule 专属的带外扫描。 -- 侧边栏闹钟始终是尽力而为、由 cache 支撑的列表呈现,绝不会变成 runtime 存活标识。 -- 不同查看者的浏览器时间标签可能因 locale、时区与时钟而不同,持久记录仍完全相同。 -- 损坏的 Schedule history 会使正常 Session 路径失败,绝不会降级成貌似可信的部分目录。 -- 该目录不能确认、重试、编辑或证明交付;这些语义有意留在此界面之外。 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml new file mode 100644 index 0000000000..657deadb04 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.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-08-26-web-trigger-menu-presentation-polish.md +2026-08-26-web-trigger-menu-presentation-polish.md: 07417b29e3bd520fe488fd463a44c373f694f383 +2026-08-26-web-trigger-menu-presentation-polish.zh.md: dc84c5396451d9ac504e360e8aa8e4fe858297ef diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md new file mode 100644 index 0000000000..07417b29e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md @@ -0,0 +1,35 @@ +# Agent Note: Web trigger menu presentation polish + +Status: implemented + +English | [中文](2026-08-26-web-trigger-menu-presentation-polish.zh.md) + +## Problem + +The Web composer's `/` and `@` trigger menu carried several presentation defects that made the reference flow harder to read and operate. Candidate rows spelled their kind as a localized text prefix (`Folder · name/`, `Session · label`) that duplicated the section title and pushed the name right. Pointer hover used a CSS `:hover` tint while keyboard navigation drove the reducer-owned highlight, so two rows could look focused at once. The drillable-folder affordance was a raw `›` text glyph, unlike every other chevron in the composer, and nothing told the user that Tab drills into the highlighted folder. The pending-source state was a bare "Loading…" text row. The editable `@dir/` text a drill leaves behind rendered a folder icon before the `@`, visually double-marking a token that is not a settled chip. The composer placeholders never mentioned that `/` and `@` exist ([#3080](https://github.com/deepseek-harness/deepseek-harness/issues/3080)). + +## Decision + +Candidate rows lead with a domain icon instead of a text prefix: `InputTriggerCandidate.icon` narrows from `string` to the closed union `InputTriggerCandidateIcon` (`file | folder | session`), the menu view maps it to `ReferenceIcon`, and `ui-reference` emits bare names (`folderx/`, session label). The `candidate.file`/`candidate.folder`/`candidate.session` locale keys are deleted; the session section title is `对话`/`Sessions`. The menu spans the composer card edge to edge (`left: 0; right: 0`), and a pending source renders two breathing skeleton bars in item cell metrics instead of the loading text row. + +Pointer and keyboard share one highlight, last input wins: a `hover` MenuEvent parks the reducer-owned highlight on a ready row, `MenuView` routes it from `onMouseMove` (not `mouseenter`, so keyboard-scrolling rows under a resting pointer cannot steal the highlight back), and the CSS `:hover` tint is gone. + +The drill affordance on the highlighted folder row is the library `IconChevronRightOutline14` in the quiet `--dsw-alias-label-caption` tint the access-mode chevron uses, preceded by a localized "Browse folder" caption and a `Tab` keycap that reveal only while the row holds the shared highlight. + +A token still carrying its trigger character is editable text, not a settled chip: the text-ref decoration colors it and nothing more, and the domain icon belongs exclusively to the settled `ReferenceChipNode`. The former appearance channel (scan `appearance` field, `TextRefNode.__appearance`, `data-ref-appearance` DOM attribute, CSS `::before` icon) is deleted end to end. + +Composer placeholders advertise both triggers (`描述你想要构建的内容… / 调用指令 @ 文件或对话` / `Describe what you want to build... / commands, @ files or sessions`), and the zh copy for commands is unified from 命令 to 指令 across `ui-chat`, `ui-conversation`, `ui-goal`, and `ui-input-trigger`. + +## Alternatives considered + +**Keep the CSS `:hover` tint alongside the keyboard highlight.** Rejected: two rows can look focused at once while `aria-activedescendant` names only one, and Enter acts on the keyboard row while the eye may rest on the hovered one. + +**Route hover from `mouseenter`.** Rejected: when arrow keys scroll new rows under a stationary pointer, each row entering the pointer re-fires `mouseenter` and steals the highlight the user just moved; `mousemove` fires only on real pointer motion. + +**Keep the folder icon on the editable `@dir/` text.** Rejected: the icon before the trigger character double-marks the token and erases the visual distinction between "still editable text" and "settled chip"; reserving the icon for the chip makes the two states readable at a glance. + +**Show the Tab hint on every drillable row.** Rejected: idle rows carrying persistent keycaps add noise; the hint teaches the key exactly when it applies — while that row is the one Tab would act on. + +## Consequences + +The kind information every row used to spell in text now rides the icon and section title; a future candidate kind must extend `InputTriggerCandidateIcon` and pick an icon rather than pass an arbitrary string. Pointer motion round-trips through the reducer (`hover` is a no-op for the already-highlighted row, so mousemove storms do not churn state). Drill discoverability rests on the highlight: an idle folder row shows only its chevron until hovered or reached by keys. Deferred follow-ups — settle-on-space for exact-match tokens and `name` vs `name/` labels — remain tracked in [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154); candidate description content, back navigation after a drill, and reference search latency are settled by the [@ mention discovery and row content note](2026-08-27-web-at-mention-discovery-and-row-content.md). diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md new file mode 100644 index 0000000000..dc84c53964 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md @@ -0,0 +1,35 @@ +# Agent Note:Web 触发菜单呈现打磨 + +Status: implemented + +[English](2026-08-26-web-trigger-menu-presentation-polish.md) | 中文 + +## Problem + +Web composer 的 `/` 与 `@` 触发菜单存在多处呈现缺陷,使引用流程更难阅读和操作。候选行用本地化文字前缀标注类型(`Folder · name/`、`Session · label`),既与 section 标题重复又把名称挤向右侧。指针悬停用 CSS `:hover` 着色,而键盘导航驱动 reducer 持有的高亮,两行可能同时呈现焦点态。可下钻文件夹的操作标记是裸文本 `›`,与 composer 中其他 chevron 不一致,且没有任何提示告诉用户 Tab 可以下钻高亮的文件夹。来源加载中状态是一行"正在加载…"文字。下钻留下的可编辑 `@dir/` 文本在 `@` 前渲染文件夹图标,对一个并非 settled chip 的 token 形成视觉双重标记。composer 的 placeholder 从未提及 `/` 和 `@` 的存在([#3080](https://github.com/deepseek-harness/deepseek-harness/issues/3080))。 + +## Decision + +候选行以领域图标开头,不再用文字前缀:`InputTriggerCandidate.icon` 从 `string` 收窄为封闭联合 `InputTriggerCandidateIcon`(`file | folder | session`),菜单视图将其映射到 `ReferenceIcon`,`ui-reference` 只输出裸名称(`folderx/`、session 标签)。删除 `candidate.file`/`candidate.folder`/`candidate.session` 三个 locale 键;session section 标题改为 `对话`/`Sessions`。菜单与 composer 卡片左右等宽(`left: 0; right: 0`),加载中的来源渲染两条与候选行同尺寸的呼吸骨架条,替代加载文字行。 + +指针与键盘共享单一高亮,后到者优先:新增 `hover` MenuEvent 把 reducer 持有的高亮停在某个就绪行上,`MenuView` 从 `onMouseMove` 路由(不用 `mouseenter`,避免键盘滚动把新行送到静止指针下时抢回高亮),CSS `:hover` 着色整体移除。 + +高亮文件夹行上的下钻标记改为库内 `IconChevronRightOutline14`,使用访问模式 chevron 同款的弱色 `--dsw-alias-label-caption`,左侧为本地化"进入目录"文字加 `Tab` 键帽提示,仅当该行持有共享高亮时显示。 + +仍带触发符的 token 是可编辑文本而非 settled chip:text-ref 装饰只做染色,领域图标专属于 settled 的 `ReferenceChipNode`。原有的 appearance 通道(扫描的 `appearance` 字段、`TextRefNode.__appearance`、`data-ref-appearance` DOM 属性、CSS `::before` 图标)端到端删除。 + +composer placeholder 同时提示两个触发符(`描述你想要构建的内容… / 调用指令 @ 文件或对话` / `Describe what you want to build... / commands, @ files or sessions`),并将 `ui-chat`、`ui-conversation`、`ui-goal`、`ui-input-trigger` 中命令的中文文案统一为"指令"。 + +## Alternatives considered + +**保留 CSS `:hover` 着色与键盘高亮并存。** 被否:两行可能同时呈现焦点态,而 `aria-activedescendant` 只指向一行;Enter 作用于键盘行,视线却可能停在悬停行上。 + +**从 `mouseenter` 路由悬停。** 被否:方向键把新行滚动到静止指针下方时,每个进入指针的行都会重新触发 `mouseenter`,抢走用户刚移走的高亮;`mousemove` 只在指针真实移动时触发。 + +**保留可编辑 `@dir/` 文本上的文件夹图标。** 被否:触发符前的图标对 token 形成双重标记,抹掉了"仍可编辑的文本"与"settled chip"之间的视觉区分;图标专属于 chip 才能让两种状态一眼可辨。 + +**在所有可下钻行上常驻 Tab 提示。** 被否:空闲行常驻键帽增加噪音;提示恰好在其生效时出现——该行正是 Tab 将作用的行。 + +## Consequences + +过去每行用文字拼写的类型信息现在由图标和 section 标题承载;未来新增候选类型必须扩展 `InputTriggerCandidateIcon` 并选定图标,而非传任意字符串。指针移动经 reducer 往返(`hover` 对已高亮行是 no-op,mousemove 风暴不会搅动状态)。下钻的可发现性依赖高亮:空闲文件夹行在被悬停或键盘到达前只显示 chevron。延后的跟进项——精确匹配 token 的空格 settle、`name` 与 `name/` 标签——仍记录在 [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154);候选 description 内容、下钻后的回退导航与引用搜索延迟由 [@ mention 发现与行内容笔记](2026-08-27-web-at-mention-discovery-and-row-content.zh.md) 结清。 diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml new file mode 100644 index 0000000000..81fd019718 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.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-08-27-web-at-mention-discovery-and-row-content.md +2026-08-27-web-at-mention-discovery-and-row-content.md: ae27f98b07d7a837e095f95129b355770fc8ac02 +2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8569473d1dec28f371ae8cc12ed50081d2642ab4 diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md new file mode 100644 index 0000000000..ae27f98b07 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md @@ -0,0 +1,67 @@ +# Agent Note: Web @ mention discovery cost and row content + +Status: implemented + +English | [中文](2026-08-27-web-at-mention-discovery-and-row-content.zh.md) + +## Problem + +Typing after `@` in the Web composer was slow, and the menu it filled was padded with text that distinguished nothing. Three defects sat behind that, all reachable from one keystroke. + +Session discovery read every persisted session's whole log. `listCandidates` sliced to the candidate limit only for an empty query; a non-empty one called `readTitleSnapshots` over the entire corpus, and folding a title there costs one full log read per session. `DEFAULT_PREPARED_SESSION_CACHE_SIZE` is 5, so any real corpus evicts faster than it fills and every keystroke pays the cold price again. Measured against a 342-session store: 1139 ms of multi-frame zstd decompression and JSON parsing per keystroke, at concurrency 4 with a warm page cache. That is the shape users reported — `@` alone was tolerable at roughly 160 ms because it sliced first; one typed character was not. + +The file index was truncating half of a workspace. `WorkspaceFileSearch` fills breadth-first under `maxEntries`, so a cap reached at depth four or five drops everything deeper. This repository holds 19 764 entries against a 10 000 cap, of which 8 148 (41%) were `lib/` build output that the two default exclusions (`.git`, `node_modules`) did not cover. `@AssistantMarkdown` returned nothing for a file that exists; `@MenuView` returned its spec file and not `MenuView.tsx`. Separately, any `tool/result` invalidated the whole index, so a read-only tool put a full traversal in front of the next caret. + +Row content repeated itself. A workspace-root file rendered `reference.txt reference.txt`, because the description was the full path and the name was its basename. A session row rendered its title, its full session id, its full cwd, and a raw `toISOString()` timestamp. A drilled directory listing had no way back except deleting characters, and every row in it named the same parent. + +Web e2e could not see any of this: its scaffold pins an isolated `DSH_HOME` holding two sessions. + +## Decision + +**A discovery label is a projection read, never a log read.** `SessionReferenceResolver` asks each listed session's projections for its title and takes its id when none answers. Attachment is decided by the session store at read time, not by the listing that produced the record, so a session that attached in between is never answered from a checkpoint its live log has moved past. An attached session answers from `ctx.sessionProjections.snapshot(session, ['title'])` — the live cut, which advances with every committed event, over events already in memory. A cold one answers from `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])`, the durable checkpoint written when it went cold. Both are synchronous and touch no log. + +Folding a title from a log costs the whole log, and this call sits under every keystroke of `@` completion, so it is not attempted at all. A session no projection answers for — one persisted before the cache was composed, or seeded straight to disk — is labeled by its id and cannot be found by its title. That state is self-healing: opening the session once attaches it, and disposal checkpoints it. + +**An invalidated file index keeps answering while its replacement builds.** `invalidate()` bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A traversal whose root is unreadable rejects rather than settling: an unreadable branch costs its own candidates, but an unreadable root learned nothing, and publishing that as an empty index would replace entries that are still good and leave no invalidation to retry from. A failed refresh leaves the stale entries and the counter behind, so the next query retries. `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` grows from two names to fifteen — version-control and dependency stores plus build-output basenames no ecosystem also uses for sources — and `DEFAULT_FILE_SEARCH_MAX_ENTRIES` rises to 50 000. Both remain `excludedDirectories` and `maxEntries` config fields a deployment overrides. + +**Rows carry only what distinguishes them.** A file names its parent directory and nothing at the workspace root. A drilled directory listing names no parent, because its breadcrumb does. A session names its workspace only when `SessionReferenceCandidate.sameWorkspace` is false — the host computes that, since it already holds both working directories for ranking — and is dated from the Host session list's `updatedAt` through the relative-time bucket that list uses, so one session reads the same age on both surfaces. A session the list does not carry falls back to the candidate's `createdAt`. `relativeTime` moves from `ui-workspace`'s `tree.ts` to `ui-primitives`; the words stay in each plugin's own dictionary, per locale-owned copy. The session id leaves the row: it is already the label a session without a title falls back to. + +**A drill publishes a breadcrumb; typing a path does not.** `InputTriggerSource` gains an optional synchronous `header(session, req)` hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned `drilled` flag. The flag is set only when the drill's edit actually reached the draft — a refused edit leaves it clear, so a header never names a directory nobody descended into — and it survives further typing until the menu closes. `CandidateRequest` carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through `onPick` with `action: 'drill'` — returning to a step and descending into one are one outcome. `MenuView` renders the header above its scrolling viewport and moves `role="listbox"` onto that viewport, because a breadcrumb is not an option and a listbox may not carry one. + +The zh composer placeholder says `文件或对话`, matching the `对话` section title the same menu already shows. + +## Alternatives considered + +**Fold the missing titles from their logs, memoized per cold log.** Implemented first, then removed in review. It made the first filtered query over a corpus the cache had not covered read those logs — on a 342-session store, roughly 190 of them — to rescue sessions that predate the cache. Correlating that store against the cache's arrival showed why the trade is bad: every session the product writes today gets a checkpoint at creation, `turn/end`, and disposal, and an old session acquires one the first time it is opened. The gap is legacy data that heals on contact, not a shape discovery has to pay for on every keystroke. + +**Read a cold session's title through `sessionQuery.observeSession` or `persistence.readFrom`.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read. + +**Debounce the candidate fetch.** Rejected. The reducer already resets every group to pending on each hit, so a trailing debounce extends the skeleton state and reads as *slower* while typing. With the fold removed, the round trip no longer justifies the timer; keeping the previous rows visible under a new generation is a separate decision with pick-safety consequences, and is not taken here. + +**Read `.gitignore` to bound the index.** Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list stays a config field a workspace overrides. + +**Exclude `lib` by default with the other build outputs.** Rejected: Ruby gems and many npm packages keep their sources there, and the miss would be silent and total rather than the partial truncation this change removes. This repository builds into `lib` and adds it through `excludedDirectories`; the shipped default names only outputs no ecosystem also uses for sources. + +**Read the session's last activity on the host, from the `sessionListMetadata` projection.** Rejected: that projection key is declared by `api-session-controller`, so reading it would make a `packages/context` capability depend on the BFF assembly — a direction with no precedent in this repository. The client already holds the same number in `ctx.sessions.list`, which is also what makes the two surfaces agree by construction rather than by coincidence. + +**Let `MenuView` recognize the `@` trigger and draw the breadcrumb itself.** Rejected: `MenuView` is shared with `/`, and hardcoding file-reference semantics there crosses the package boundary the source registry exists to hold. + +**Add a `drilled` flag to `CandidateRequest` as optional.** Rejected: the pipeline always knows it, and an optional field invites a source to read `undefined` as "not drilled" for a request that simply predates the field. Required, with every call site updated, matches the pre-release stance. + +## Consequences + +A deployment without `session-projection-cache` composed labels every cold session by its id; without `session-projections` too, every session. Discovery is as complete as the projections it reads, and never slower than them. + +A store carrying sessions from before the cache shipped shows those sessions by id until each is opened once. On the machine this change was measured against that is roughly 190 of 342 — visible to a long-time user, invisible to a new one, and shrinking with use. + +The file index is one invalidation stale: a bare query answered immediately after a tool result reflects the tree as of the previous traversal, and the following query sees the rebuild. Sources kept under an excluded basename need an `excludedDirectories` override. + +`aria` goldens change shape: the listbox role now sits on an inner element, and rows carry a relative-time bucket that advances while a suite runs. `normalizeAria` collapses that vocabulary to `{{age}}` before the duration rules, anchored on an aria label's closing quote. + +The reference row content is now derived from what the neighbouring chrome already shows — the breadcrumb for a drilled listing, the current workspace for a session. A future surface that renders these candidates without that chrome would show less than it should, and must ask the source for a different projection rather than re-deriving paths. + +## Testing + +Package tests cover a renamed attached session found by its new title while its checkpoint still holds the old one, a cold session labeled from its checkpoint, an unprojected session labeled by its id, a composition with no projection face at all, `readTitleSnapshots` never called on any of those paths, stale-while-revalidate driven through the real filesystem — a root that vanishes under a live index keeps answering and picks the workspace back up when it returns — an unreadable subtree costing only its own candidates, a `lib` tree that stays searchable, and the breadcrumb contract from both ends including a refused drill edit. `reference-composer.e2e.ts` covers the shipped composition: the refreshed menu golden shows the trimmed rows, and a new case drills into a folder, asserts the breadcrumb appears only then, and clicks the root crumb back to a bare `@`. Its seeded sessions appear there as ids, because a seed reaches disk as a log alone and this scaffold seeds after the host has already loaded its projection-cache table; seeding before boot would give the app a populated session list at startup, which the fresh-workspace flow four scenarios share does not expect. The titled paths stay in the package suite, and the e2e asserts the id labels it actually produces rather than a title the fixture cannot carry. + +The 1139 ms figure is a measured floor for the server-side I/O against a real store, not an instrumented end-to-end UI latency; the web e2e scaffold's isolated `DSH_HOME` cannot reproduce the corpus that produces it. diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md new file mode 100644 index 0000000000..8569473d1d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md @@ -0,0 +1,67 @@ +# Agent Note:Web @ mention 的发现成本与行内容 + +Status: implemented + +[English](2026-08-27-web-at-mention-discovery-and-row-content.md) | 中文 + +## Problem + +在 Web composer 里 `@` 之后继续输入很慢,而被填满的菜单里塞着并不能区分候选的文字。背后是三个缺陷,一次击键就能全部触达。 + +会话发现会读取每个持久化会话的完整日志。`listCandidates` 只对空查询先截断到候选上限;非空查询对整个语料调用 `readTitleSnapshots`,而在那里折叠一个标题的代价是完整读一遍该会话的日志。`DEFAULT_PREPARED_SESSION_CACHE_SIZE` 是 5,因此任何真实语料的淘汰速度都快于填充速度,每次击键都重新付冷读的代价。在一个 342 会话的存储上实测:并发 4、页缓存已热的前提下,每次击键 1139 ms 的多帧 zstd 解压与 JSON 解析。这正是用户描述的形状——单独敲 `@` 因为先截断而尚可忍受(约 160 ms),多打一个字符就不行了。 + +文件索引截断了半个工作区。`WorkspaceFileSearch` 在 `maxEntries` 之下按广度优先填充,因此在第四、五层触顶就会丢弃更深的一切。本仓库有 19 764 个条目而上限是 10 000,其中 8 148 个(41%)是两个默认排除项(`.git`、`node_modules`)覆盖不到的 `lib/` 构建产物。`@AssistantMarkdown` 对一个真实存在的文件返回空;`@MenuView` 返回它的 spec 文件而不是 `MenuView.tsx`。另外,任意 `tool/result` 都会使整个索引失效,因此一个只读工具就会把一次完整遍历挡在下一个光标前面。 + +行内容自我重复。工作区根目录的文件渲染成 `reference.txt reference.txt`,因为 description 是完整路径而 name 是它的基名。会话行渲染标题、完整 session id、完整 cwd 和一个原始的 `toISOString()` 时间戳。下钻后的目录列表除了删字符没有回退方式,而且其中每一行都写着同一个父目录。 + +Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的隔离 `DSH_HOME`。 + +## Decision + +**发现用的标签只来自投影读,绝不读日志。** `SessionReferenceResolver` 向每个被列出的会话的投影索取标题,无人作答就用它的 id。是否挂载由会话存储在读取时决定,而不是由产生该记录的那次列举决定,因此在两者之间挂载上来的会话绝不会被一份其实时日志已经越过的 checkpoint 作答。已挂载的会话由 `ctx.sessionProjections.snapshot(session, ['title'])` 作答——那是随每个已提交事件推进的实时切面,事件本就在内存里。冷会话由 `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])` 作答,即它转冷时写下的持久化 checkpoint。两者都是同步的,都不碰日志。 + +从日志折叠一个标题的代价是整份日志,而这次调用位于 `@` 补全每一次击键之下,所以干脆不做。没有任何投影能作答的会话——早于缓存组合存在的、或被直接 seed 到磁盘的——用 id 作标签,且无法按标题搜到。这个状态会自愈:把该会话打开一次即挂载,销毁时就写下 checkpoint。 + +**失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。根目录不可读的遍历会失败而不是落定:不可读的分支只损失它自己的候选,而不可读的根意味着这次遍历什么都没学到,把它作为空索引发布会覆盖掉仍然有效的条目,且不留下任何可供重试的失效标记。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十五个——版本控制与依赖目录,加上没有任何生态用作源码目录的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories` 与 `maxEntries` 配置字段。 + +**每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算,因为排序时它本就同时握有两个工作目录——并用宿主会话列表的 `updatedAt` 经该列表所用的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。列表中没有的会话回落到候选自带的 `createdAt`。`relativeTime` 从 `ui-workspace` 的 `tree.ts` 移到 `ui-primitives`;按 locale-owned 文案的规则,词句仍留在各插件自己的字典里。session id 离开行内:它本就是无标题会话回落到的标签。 + +**下钻会发布面包屑,键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑,在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问,该标记只在下钻的编辑真正落到草稿上时才置位——被拒绝的编辑保持清零,因此头部绝不会指向没人进去过的目录——并在菜单关闭前跨越后续键入。`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store,冻结的菜单归约器因此对它一无所知;点击面包屑经 `onPick` 以 `action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上,并把 `role="listbox"` 移到该视口上,因为面包屑不是选项,listbox 也不得承载它。 + +中文 composer placeholder 改为 `文件或对话`,与同一个菜单已经显示的 `对话` 分组标题一致。 + +## Alternatives considered + +**从日志折叠缺失的标题,并按冷日志记忆化。** 先实现了,评审时移除。它会让缓存尚未覆盖的语料在首次过滤查询时读那些日志——在 342 会话的存储上约 190 份——只为救回早于缓存存在的会话。把该存储与缓存的上线时间对照后可以看出这笔买卖不划算:今天产品写出的每个会话都会在创建、`turn/end` 与销毁三处建立 checkpoint,而旧会话只要被打开一次就会补上。缺口是「一碰即愈」的存量数据,不是发现路径每次击键都该付的形状。 + +**通过 `sessionQuery.observeSession` 或 `persistence.readFrom` 读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读。 + +**给候选拉取加防抖。** 否决。归约器在每次命中时已经把所有分组重置为 pending,因此尾部防抖会延长骨架状态,输入时读起来更慢。折叠成本移除后,往返时间不再值得一个定时器;在新 generation 下保留上一批行是另一个决定,带有误选后果,此处不做。 + +**读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表本就是工作区可覆盖的配置字段。 + +**把 `lib` 和其余构建产物一起放进默认排除。** 否决:Ruby gem 与相当一部分 npm 包的源码就在那里,而这次缺失会是无声且彻底的,比本次改动所消除的部分截断更糟。本仓库构建进 `lib`,通过 `excludedDirectories` 自行加上;随附默认值只列没有任何生态用作源码目录的产物名。 + +**在宿主侧从 `sessionListMetadata` 投影读取会话最近活动时间。** 否决:该投影键由 `api-session-controller` 声明,读取它会让 `packages/context` 的能力依赖 BFF 装配层——本仓库没有这个方向的先例。客户端的 `ctx.sessions.list` 里本就有同一个数字,而这也正是让两处界面「由构造而非由巧合」保持一致的原因。 + +**让 `MenuView` 识别 `@` 触发符并自行绘制面包屑。** 否决:`MenuView` 与 `/` 共用,把文件引用语义硬编码进去,越过了 source 注册表本就用来守住的包边界。 + +**把 `drilled` 作为可选字段加进 `CandidateRequest`。** 否决:管线始终知道它,而可选字段会诱使 source 把「请求早于该字段」读成「未下钻」。改为必填并更新每一处调用点,符合预发布阶段的取舍。 + +## Consequences + +未组合 `session-projection-cache` 的部署把每个冷会话都标成 id;连 `session-projections` 也没有时,所有会话都是 id。发现能力与它所读的投影一样完整,且绝不会比投影更慢。 + +存有「缓存上线之前的会话」的存储,会把那些会话显示成 id,直到各自被打开一次。在本次实测的机器上约为 342 个里的 190 个——老用户看得见,新用户看不见,且随使用递减。 + +文件索引落后一次失效:紧接工具结果之后的裸查询反映的是上一次遍历时的目录树,下一次查询才看到重建结果。把源码放在被排除基名下的工作区需要覆盖 `excludedDirectories`。 + +`aria` golden 的形状改变:listbox 角色现在落在内层元素上,且行内携带会随套件运行而推进的相对时间分档。`normalizeAria` 在 duration 规则之前把该词汇归一为 `{{age}}`,锚定在 aria 标签的右引号上。 + +引用行的内容现在派生自相邻 chrome 已经显示的信息——下钻列表的面包屑、会话的当前工作区。未来若有不带这些 chrome 的界面渲染同一批候选,它显示的信息会不足,必须向 source 索取另一种投影,而不是自行重新推导路径。 + +## Testing + +包级测试覆盖:被改名的挂载会话在 checkpoint 仍是旧值时按新标题被搜到、冷会话由 checkpoint 标注、无投影可答的会话标成 id、完全没有投影面的组合、以上路径均未调用 `readTitleSnapshots`、经真实文件系统驱动的 stale-while-revalidate——根目录在活索引之下消失时仍继续作答,并在它回来后自动接上——不可读子目录只损失自身候选、`lib` 目录仍可搜索,以及面包屑契约的两端(含被拒绝的下钻编辑)。`reference-composer.e2e.ts` 覆盖随附组合:刷新后的菜单 golden 显示精简后的行,新增用例下钻进入文件夹、断言面包屑只在此时出现、并点击根节点回到裸 `@`。其中被 seed 的会话在那里显示为 id,因为 seed 落到磁盘的只有日志,而该 scaffold 在宿主已载入投影缓存表之后才 seed;把 seed 提前到 boot 之前会让应用启动时就带着一份会话列表,而四个场景共用的「连接新工作区」流程并不预期这一点。带标题的路径留在包级测试里,e2e 断言它真正产生的 id 标签,而不是这个 fixture 承载不了的标题。 + +1139 ms 是针对真实存储的服务端 I/O 实测下限,不是插桩得到的端到端 UI 延迟;web e2e scaffold 的隔离 `DSH_HOME` 无法复现产生该数字的语料。 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index df45c55f57..dcd1be33a0 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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 .agents/notes/implemented/process/2026-07-20-gui-testing-system.md -2026-07-20-gui-testing-system.md: 8656507ba85b187fea333b100762d4250256845f -2026-07-20-gui-testing-system.zh.md: d9dee0d531bfebafca02bbe765a271ee39e6e6a0 +2026-07-20-gui-testing-system.md: 41cf4fa93b63c4cf9bf9e1327983d36c0c98ebfb +2026-07-20-gui-testing-system.zh.md: 603124368e49cab8755a9c93885e85b2e288b0a3 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 8656507ba8..41cf4fa93b 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -18,7 +18,7 @@ Cut along the architecture's natural test hooks into three tiers, bottom-up: | Tier | Under test | Key technique | File location | |---|---|---|---| -| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | +| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/client/connection/tests/` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | | 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index d9dee0d531..603124368e 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -18,7 +18,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 层 | 被测物 | 关键手段 | 文件落点 | |---|---|---|---| -| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD 类型/SSE(Server-Sent Events)流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | +| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD 类型/SSE(Server-Sent Events)流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/client/connection/tests/` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | | 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以确定性方式驱动跨插件状态变化;另有最简 Playwright 冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture(测试前置数据),与会话区 aria 预期输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml index a43189052c..8620a0e642 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.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 .agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md -2026-07-22-tsconfig-solution-root-two-aggregates.md: f12f2e7c4b46eacb376ff443ad9080fcd367490f -2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 9c1fc006e7d509fef8e90fc113849f4ef14c2184 +2026-07-22-tsconfig-solution-root-two-aggregates.md: 714f7c4b6ac9839ab51c8453f970346a6f18f17e +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 53fe3c4342b78cc90b537fdb5c2dbb29387fbe85 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md index f12f2e7c4b..714f7c4b6a 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md) ## Problem -The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: +The GUI split introduced a second aggregate program (`tsconfig.client.json`, [archived layering RFC](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: - The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph). - The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md index 9c1fc006e7..53fe3c4342 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: +GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[已归档的分层 RFC](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: - 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。 - lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml index c72c3384fa..2061bd878b 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.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 .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md -2026-07-26-web-syntax-highlighting-shiki.md: 967df645db3df0a33f9cd1f34e22e912a348c30e -2026-07-26-web-syntax-highlighting-shiki.zh.md: 3ac1d6bc17954418566b683c2a1d64dfb79f3434 +2026-07-26-web-syntax-highlighting-shiki.md: ba50318d306426d886af25f01eba616ab919d237 +2026-07-26-web-syntax-highlighting-shiki.zh.md: 5533bcb5348db152a8f9466eae7d3d8d9a726162 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md index 967df645db..ba50318d30 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-web-syntax-highlighting-shiki.zh.md) -> Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the Code Mode UI stack; the [chat sub-call rows note](../feature/2026-07-26-code-mode-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). +> Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the PTC mode UI stack; the [chat sub-call rows note](../feature/2026-07-26-ptc-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). ## Problem diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md index 3ac1d6bc17..5533bcb534 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-web-syntax-highlighting-shiki.md) | 中文 -> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表约定与各消费表面。本篇是 Code Mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-code-mode-chat-subcall-rows.zh.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则由 [Web 样式体系裁决](2026-07-19-web-styling-system.zh.md)规定。 +> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表约定与各消费表面。本篇是 PTC mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-ptc-chat-subcall-rows.zh.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则由 [Web 样式体系裁决](2026-07-19-web-styling-system.zh.md)规定。 ## 问题 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 9ad937a5df..f054e63897 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.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 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: 2f5da20c947e60a3c76514631d1c64c38e07b4cd -2026-08-08-api-remotes-generated-contract-build.zh.md: 92d840765a4297d76dfdbab851869c90ac8e6609 +2026-08-08-api-remotes-generated-contract-build.md: 319b0205aacfe1c5d81ca5093199cdd1818a8132 +2026-08-08-api-remotes-generated-contract-build.zh.md: 21ed4593f01cb85f54dc1c75f3b0f269462c0fa0 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index 2f5da20c94..319b0205aa 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate references `api/remotes/tsconfig.host.json`, while the root Client aggregate and direct Client consumers reference `api/remotes/tsconfig.client.json`. `session-log-export` uses the same solution-and-leaves structure to keep its Node archive implementation out of its browser controller. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md index 92d840765a..21ed4593f0 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 引用 `api/remotes/tsconfig.host.json`,根 Client aggregate 与直接 Client 消费方引用 `api/remotes/tsconfig.client.json`。`session-log-export` 使用相同的 solution 与 leaf 结构,让 Node archive 实现不进入浏览器 controller。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml rename to .agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.i18n.yaml index 38e07804b7..4389f9a1dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.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 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 372bf4926011835999ebae9b1e2d1f5beb8eb663 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: ecce57c01c155c2a0b19b7729da13c39d1a520a6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.md +2026-08-27-explicit-workspace-path-aliases.md: 51d2dee7911d323637121f509e160ef92c352568 +2026-08-27-explicit-workspace-path-aliases.zh.md: 702d3d7a8cf91c8c307b1708fc30faad6cab009a diff --git a/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.md b/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.md new file mode 100644 index 0000000000..51d2dee791 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.md @@ -0,0 +1,61 @@ +# Agent Note: Explicit workspace path aliases replace per-group wildcards + +Status: implemented + +English | [中文](2026-08-27-explicit-workspace-path-aliases.zh.md) + +## Problem + +`tsconfig.base.json` is the resolution facade for the whole repository: every package project extends it, both aggregates read it, and every Vitest config points `vite-tsconfig-paths` at it. Two of its aliases carried one candidate per package *group* rather than one per package — `@deepseek-ai/dsh-*` listed 49 candidate globs and `@deepseek-ai/dsh-*/invariant` listed 45. + +TypeScript and tsx try those candidates in order and take the first that exists, so a specifier whose package sits late in the list pays for every earlier miss. Under the `dsh` source launch each miss is an `ERR_MODULE_NOT_FOUND` that Node decorates with `decorateErrorWithCommonJSHints`, which runs a full CommonJS resolution walk per failure. A profile of a source-launch boot attributed 934.6 ms — 35% of the boot — to that decoration path alone, from 60,942 failed resolutions. + +The cost fell hardest on the most-imported packages. `packages/util/*` sat at position 44 of 49 and holds the leaf utilities nearly every plugin imports, so `dsh-timeout` paid roughly 9 ms per resolution against 0.05 ms for a specifier with an explicit alias. + +## Decision + +`scripts/gen-tsconfig-paths.ts` writes one explicit alias per workspace package into a marked region at the end of `paths`, and both group wildcards are deleted. `pnpm run gen-tsconfig-paths` rewrites the region; `pnpm run verify-tsconfig-paths` reports drift instead, and runs in the `ci-static` lane beside the other generated-artifact checks. + +The generator emits an alias only for a package whose declared name is exactly `@deepseek-ai/dsh-`, because that is the only shape a wildcard could ever have resolved: it substituted the specifier's suffix into `packages///src`. Packages named after something other than their directory — `@deepseek-ai/dsh-typert-protocol` at `packages/typert/protocol`, the `dsh-client-*` and `dsh-host-*` families — already carry hand-written aliases and are left alone. A specifier claimed by two package directories throws rather than picking one, because an explicit map cannot express the group-order tiebreak the wildcard used; no such collision exists today. + +Deleting the wildcards removed the fallback that used to resolve a package nobody had aliased, so the generator also asserts coverage: every workspace package carrying a `src` directory must be mapped by a generated or hand-written alias, and `--check` names any that is not. Without it a package whose name does not match its directory could be added, skipped by the generator, and left resolving through the workspace symlink to built `lib/` output — the same artifact-plane leak the explicit aliases exist to close. + +The region is written by text surgery between marker comments rather than by re-serializing the file. `tsconfig.base.json` is JSONC and its hand-written aliases carry comments that explain non-obvious mappings; re-serializing would drop them. + +This partly supersedes the [package-inventory discovery proposal](../../proposed/process/2026-06-20-discover-package-inventory.md), which records the collapse into one wildcard as current: the wildcard is gone, while that proposal's remaining subject — the aggregate configs' explicit `references` arrays — is untouched here. + +Four wildcards remain, each with a single candidate: `dsh-host-*/invariant`, `dsh-client-*/invariant`, `dsh-client-*/client`, and the five `dsh-host-/*` subpath maps. One candidate costs one probe, so expanding them would trade file size for nothing. + +## Resolution differences this change makes + +Every `@deepseek-ai/dsh-*` specifier appearing in repository sources — 1,023 distinct — resolves to the same target as before, with eleven exceptions that now resolve where they previously did not. All eleven previously reached built `lib/` output through the workspace symlink rather than source. + +Seven are `/invariant` subpaths: `dsh-invariants/invariant`, `dsh-lsp/invariant`, `dsh-lsp-stdio/invariant`, `dsh-tool-lsp/invariant`, `dsh-terminal/invariant`, `dsh-terminal-bash/invariant`, and `dsh-tool-terminal/invariant`. + +The deleted `dsh-*/invariant` wildcard omitted the `lsp`, `terminal`, `client`, and `host` groups. The `client` and `host` omissions are deliberate and documented — those families have dedicated wildcards because their package names prefix the group directory. The `lsp` and `terminal` omissions have no such reason, and `packages/runtime-diagnostics/invariants` appeared in neither list. Those seven specifiers therefore resolved through the workspace symlink and the package's `./invariant` export to built `lib/types/*.d.ts` instead of to source, which contradicts the rule that static gates resolve workspace imports through `paths` to `src` and pass on a clean tree. Making the aliases uniform resolves them to source like every sibling. + +The other four are whole packages the coverage assertion surfaced: `dsh-client-ui-directory-picker-browse`, `dsh-client-ui-directory-picker-native`, `dsh-experimental-agent-team-profile`, and `dsh-experimental-agent-team-web-profile`. Each is named `dsh--`, which no wildcard could ever substitute, and each sits beside siblings that do carry hand-written aliases — they were simply missing. They now carry one too. + +## Testing + +`scripts/gen-tsconfig-paths.spec.ts` pins that the collector maps a package to its own directory, skips packages carrying hand-written aliases, and returns a sorted list; that the renderer yields to a hand-written specifier and closes the region without a trailing comma; that the region writer replaces only the marked span and refuses a config without markers; and that neither group wildcard survives in the committed config. Two further cases pin the coverage assertion: it names an unmapped package, and it reports none against the committed config. + +The gate's rejection path is exercised directly: deleting one generated alias makes `verify-tsconfig-paths` exit non-zero, and restoring it makes the check pass. + +The CLI entry guard uses the repository's established comparison, `import.meta.filename === resolve(process.argv[1])`, rather than concatenating a `file://` URL. The concatenated form fails whenever `import.meta.url` percent-encodes something `process.argv[1]` does not — a repository path containing a space, or any Windows drive path — and the failure is silent: the script exits 0 having done nothing, which would make the gate a no-op exactly where it is needed. Running a copy from a directory whose name contains a space reproduces that: the concatenated guard evaluates false, the established one true. + +## Alternatives considered + +**Reordering the wildcard's candidate globs so the hottest groups come first.** This needs no generator and no new gate, and recovers perhaps half the cost by moving `util`, `core`, `llm`, and `session` to the front. It was rejected because the win decays as packages are added, the ordering has no invariant a reader could check, and every group after the first still pays. It also leaves the worst property intact: adding a package group silently slows every boot. + +**Moving `paths` into a generated `tsconfig.paths.json` that the base config extends.** This keeps the generated content out of the hand-written file entirely and produces a cleaner diff. It was rejected for this change because several consumers read `tsconfig.base.json` directly rather than through a resolver that follows `extends` — six Vitest configs plus `project-reference-faces.ts`, `verify-export-jsdoc.ts`, `doc-typecheck.ts`, and `rescope-vendor.ts` — and auditing each is a larger change than the aliasing itself. The marker region achieves the same isolation with no consumer risk. + +**Keeping the wildcards as a fallback beneath the explicit aliases.** An explicit alias already wins over a wildcard, so correctness would be unchanged, and a package added without regenerating would keep resolving. It was rejected because the fallback is exactly what makes a stale config invisible: the repository's stance is that misconfiguration fails loud, and the `--check` gate turns a missing alias into a named failure instead of a slow boot nobody attributes. + +## Consequences + +A source-launch boot of the `headless` profile drops from a 2,157/2,182/2,153 ms baseline to 1,069/1,052/1,055 ms — about 1.1 seconds, or 51%, with the two ranges nowhere near overlapping and `--help` output byte-identical. + +The win is confined to the tsx source launch. Vitest resolves through `vite-tsconfig-paths`, which matches in-process and checks file existence without ever constructing a Node module error, so it never paid the decoration cost: an A/B over one package's suite measured 5,934/5,829/5,908 ms against 5,878/5,851/5,905 ms, which is noise. Repository gate scripts import few `@deepseek-ai/dsh-*` packages and likewise show no separable difference. Shipped users run built `lib/` under plain Node and were never affected. + +`paths` grows from 188 keys to 523, and adding a package now requires running the generator. The `--check` gate makes that a named failure rather than a silent one, and the generated region keeps the diff of such a change to a single line. diff --git a/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.zh.md b/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.zh.md new file mode 100644 index 0000000000..702d3d7a8c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.zh.md @@ -0,0 +1,61 @@ +# Agent Note: Explicit workspace path aliases replace per-group wildcards + +Status: implemented + +[English](2026-08-27-explicit-workspace-path-aliases.md) | 中文 + +## Problem + +`tsconfig.base.json` 是整个仓库的解析门面:每个包的 project 都 extends 它,两个聚合配置都读它,每个 Vitest 配置都把 `vite-tsconfig-paths` 指向它。其中两条别名按包**分组**而不是按包各写一条候选——`@deepseek-ai/dsh-*` 列了 49 个候选 glob,`@deepseek-ai/dsh-*/invariant` 列了 45 个。 + +TypeScript 与 tsx 按顺序逐个尝试这些候选、取第一个存在的,因此一个位于列表靠后位置的包,其说明符要为前面每一次未命中买单。在 `dsh` 源码启动下,每次未命中都是一个 `ERR_MODULE_NOT_FOUND`,而 Node 会用 `decorateErrorWithCommonJSHints` 装饰它——每次失败都跑一遍完整的 CommonJS 解析走查。一次源码启动的 profile 把 **934.6 ms(占启动 35%)**单独归给了这条装饰路径,来源是 60,942 次失败解析。 + +代价最重的恰好落在被引用最多的包上:`packages/util/*` 排在 49 个候选里的第 44 位,却装着几乎每个插件都要引用的叶子工具,因此 `dsh-timeout` 每次解析约付 9 ms,而一个有显式别名的说明符只要 0.05 ms。 + +## Decision + +`scripts/gen-tsconfig-paths.ts` 在 `paths` 末尾一个带标记的区域里,为每个 workspace 包写入一条显式别名,两条分组通配符随之删除。`pnpm run gen-tsconfig-paths` 重写该区域;`pnpm run verify-tsconfig-paths` 只报告漂移,并与其他生成物检查一起跑在 `ci-static` 车道上。 + +生成器只为**声明名恰好等于 `@deepseek-ai/dsh-<目录名>`** 的包发别名,因为那是通配符唯一可能解析出的形态:它把说明符的后缀代入 `packages//<后缀>/src`。名字与目录不一致的包——`packages/typert/protocol` 上的 `@deepseek-ai/dsh-typert-protocol`、以及 `dsh-client-*` 与 `dsh-host-*` 两族——本来就有手写别名,保持不动。若某个说明符被两个包目录同时认领,生成器**抛错**而不是任选其一,因为显式映射无法表达通配符依赖的分组顺序裁决;当前不存在这种冲突。 + +删除通配符也删掉了「没人写别名的包仍能解析」的兜底,因此生成器同时断言覆盖完备:每个含 `src` 的 workspace 包都必须被生成别名或手写别名映射,`--check` 会点名任何未被覆盖者。没有这条断言,一个名字与目录不一致的新包会被生成器跳过,继续经 workspace 软链解析到构建产物 `lib/`——正是显式别名要消除的产物层泄漏。 + +该区域用标记注释之间的**定点文本改写**生成,而不是重新序列化整个文件。`tsconfig.base.json` 是 JSONC,其手写别名带有解释非显然映射的注释,重新序列化会把它们丢掉。 + +本决策**部分取代**了[包清单自动发现提案](../../proposed/process/2026-06-20-discover-package-inventory.zh.md)——该提案把「合并为一个通配符」记为当前实现,而通配符已被删除;该提案剩余的主题(聚合配置里显式的 `references` 数组)不受本次改动影响。 + +保留四条通配符,每条只有一个候选:`dsh-host-*/invariant`、`dsh-client-*/invariant`、`dsh-client-*/client`,以及五条 `dsh-host-/*` 子路径映射。一个候选只花一次探测,展开它们只会换来文件变大而无收益。 + +## Resolution differences this change makes + +仓库源码中出现的每个 `@deepseek-ai/dsh-*` 说明符——共 1,023 个互不相同——解析目标与改动前完全一致,只有十一个例外:它们现在能解析,而此前不能。这十一个此前都是经 workspace 软链解析到构建产物 `lib/`,而不是源码。 + +其中七个是 `/invariant` 子路径:`dsh-invariants/invariant`、`dsh-lsp/invariant`、`dsh-lsp-stdio/invariant`、`dsh-tool-lsp/invariant`、`dsh-terminal/invariant`、`dsh-terminal-bash/invariant`、`dsh-tool-terminal/invariant`。 + +被删除的 `dsh-*/invariant` 通配符遗漏了 `lsp`、`terminal`、`client`、`host` 四个分组。其中 `client` 与 `host` 的遗漏是**刻意且有文档的**——这两族有专用通配符,因为它们的包名以分组目录名为前缀。而 `lsp` 与 `terminal` 的遗漏没有任何这类理由,`packages/runtime-diagnostics/invariants` 则两条列表都不在。于是这七个说明符此前是通过 workspace 软链与包的 `./invariant` 导出解析到构建产物 `lib/types/*.d.ts`,而不是解析到源码——这与「静态门禁通过 `paths` 把 workspace 导入解析到 `src`、并在干净树上通过」的规则相抵触。把别名统一之后,它们与所有同类一样解析到源码。 + +另外四个是被覆盖断言揪出来的**整包**:`dsh-client-ui-directory-picker-browse`、`dsh-client-ui-directory-picker-native`、`dsh-experimental-agent-team-profile`、`dsh-experimental-agent-team-web-profile`。它们都叫 `dsh-<分组>-<目录>`,任何通配符都代不出这种形态;而它们身旁的同族包都有手写别名——这四个只是漏了。现在补上。 + +## Testing + +`scripts/gen-tsconfig-paths.spec.ts` 钉住:收集器把包映射到它自己的目录、跳过带手写别名的包、返回有序列表;渲染器让位于手写说明符,且区域收尾不带多余逗号;区域写入器只替换标记范围,并在缺少标记时拒绝;以及提交后的配置里两条分组通配符都不复存在。另有两条用例钉住覆盖断言:它会点名未被映射的包,且对提交后的配置报告为空。 + +门禁的拒绝路径被直接验证过:删掉一条生成的别名会让 `verify-tsconfig-paths` 以非零码退出,恢复后检查通过。 + +CLI 入口守卫采用仓库既有的比较方式 `import.meta.filename === resolve(process.argv[1])`,而不是拼接 `file://` URL。拼接形式在 `import.meta.url` 做了百分号编码而 `process.argv[1]` 没做时失效——仓库路径含空格、或任何 Windows 盘符路径——且失效是静默的:脚本什么也不做就以 0 退出,恰恰会让这道门在最需要它的环境里形同虚设。把脚本复制到一个名字含空格的目录下运行即可复现:拼接式守卫求值为 false,既有写法为 true。 + +## Alternatives considered + +**给通配符的候选 glob 重新排序,把最热的分组放前面。** 这不需要生成器也不需要新门禁,把 `util`、`core`、`llm`、`session` 挪到前面大约能拿回一半收益。否决理由:收益随着包的增加而衰减,这个顺序没有任何读者可核验的不变量,而且第一个分组之后的每一组仍然要付钱。它还保留了最糟的性质——新增一个包分组会悄悄拖慢所有人的启动。 + +**把 `paths` 挪进一个生成的 `tsconfig.paths.json`,由 base 配置 `extends`。** 这能把生成内容彻底移出手写文件,diff 也更干净。本次否决的理由是:有若干消费者**直接读** `tsconfig.base.json`,而不是通过会跟随 `extends` 的解析器——六个 Vitest 配置,外加 `project-reference-faces.ts`、`verify-export-jsdoc.ts`、`doc-typecheck.ts`、`rescope-vendor.ts`——逐个审计它们比这次别名改造本身还大。标记区域用零消费者风险达成了同样的隔离。 + +**在显式别名下方保留通配符作为兜底。** 显式别名本来就优先于通配符,因此正确性不变,而且新增包忘了重新生成也仍能解析。否决理由:兜底恰恰是让陈旧配置隐形的原因——仓库的立场是「misconfiguration fails loud」,而 `--check` 门禁把缺失的别名变成一次具名失败,而不是一次没人归因的慢启动。 + +## Consequences + +`headless` profile 的源码启动从 2,157 / 2,182 / 2,153 ms 的基线降到 1,069 / 1,052 / 1,055 ms——约 **1.1 秒,51%**,两个区间相距极远,且 `--help` 输出逐字节一致。 + +**收益仅限于 tsx 源码启动这一条路径。** Vitest 走 `vite-tsconfig-paths`,它在进程内做匹配与文件存在性检查,从不构造 Node 模块错误,因此从未付过那笔装饰代价:对某个包的整套用例做 A/B,实测 5,934 / 5,829 / 5,908 ms 对 5,878 / 5,851 / 5,905 ms,属于噪声。仓库的门禁脚本只 import 少数几个 `@deepseek-ai/dsh-*` 包,同样测不出可分离的差异。发布用户在裸 Node 下跑构建好的 `lib/`,本来就不受影响。 + +`paths` 从 188 个 key 增长到 523 个,新增包时必须运行生成器。`--check` 门禁把这件事变成一次具名失败而非静默失败,而生成区域让这类改动的 diff 只有一行。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index b238e98e3e..efabfb37a1 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: ad8b7fcf5c79b5e3512d7fd908a41e2de47a7bce -2026-07-20-remove-stdio-and-echo-agents.zh.md: be30a44a3bdf807cc5dc6db54617c0b31e28a02b +2026-07-20-remove-stdio-and-echo-agents.md: cb0f53737a89c5324eb3712c22380b02cef7cb77 +2026-07-20-remove-stdio-and-echo-agents.zh.md: f7374fa6fba0b91a1af29f9a934d35d415c3affb diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index ad8b7fcf5c..cb0f53737a 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless expected-output test preserves assembled canonical events without creating a second CLI contract. Code Mode runs through the headless profile's `DSH_TOOLS_MODE=code` composition. Time-context integration uses its package-owned Loader composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless expected-output test preserves assembled canonical events without creating a second CLI contract. PTC mode runs through the headless profile's `DSH_TOOLS_MODE=ptc` composition. Time-context integration uses its package-owned Loader composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index be30a44a3b..f7374fa6fb 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -30,7 +30,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 预期输出测试保留组装后的规范事件,而不会创建第二套 CLI(命令行界面)约定。Code Mode 通过 headless profile 的 `DSH_TOOLS_MODE=code` 组合运行。时间上下文集成通过包自有 Loader 组合执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 预期输出测试保留组装后的规范事件,而不会创建第二套 CLI(命令行界面)约定。PTC mode 通过 headless profile 的 `DSH_TOOLS_MODE=ptc` 组合运行。时间上下文集成通过包自有 Loader 组合执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index 8c085a2343..b7f2ca1524 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.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 .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md -2026-07-22-plan-specific-collaboration-state.md: 20c363d00cfe2bcc2f01af101370f5214c948361 -2026-07-22-plan-specific-collaboration-state.zh.md: 63157b1959236e728e94a38d7ca6397a975595ef +2026-07-22-plan-specific-collaboration-state.md: 1387e69e628c5fb33b61a06214ffd67669668a13 +2026-07-22-plan-specific-collaboration-state.zh.md: 47d36eb97a85d483e40afb5fb9c44f1bf2c92d5e diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index 20c363d00c..1387e69e62 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -26,7 +26,7 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei `plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly at initial or continuation pre-step, or on a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. -The active state contributes the deployment's section at first-party prompt order 500. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. +The active state contributes the deployment's section at first-party prompt order 500. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the PTC mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. ### Reviewed exit @@ -59,7 +59,7 @@ The tool renders the submitted plan as a generic card titled by its first headin ## Verification -- Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and Code Mode schemas, review outcomes, and invariant coverage through the boolean service. +- Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and PTC mode schemas, review outcomes, and invariant coverage through the boolean service. - Command tests cover bare `/plan`, `/plan `, active `/plan off`, pending-entry cancellation, inactive idempotence, absence of `/mode` and `/review`, and effect-scoped removal. - The keyless TUI scenarios enter through `/plan `, leave through `/plan off`, and prove that each committed `plan/mode` precedes the request header it changes, the entry message is logged under plan guidance, and the post-exit request omits that guidance. - The complete `exit_plan_mode` review arc is package-tested but has no assembled-application snapshot after the interactive ACP scenarios were retired; current keyless TUI scenarios cover command entry and direct exit only. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index 63157b1959..47d36eb97a 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -26,7 +26,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` `plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩(compaction)都能恢复该状态,无需实时镜像。spawn 出的 agent(智能体)初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在初始或续步 pre-step 时,或在请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 -激活状态在 first-party 提示词顺序 500 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 +激活状态在 first-party 提示词顺序 500 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 PTC mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 ### 经评审的退出 @@ -59,7 +59,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` ## 验证 -- 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 Code Mode schema、评审结果和不变式。 +- 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 PTC mode schema、评审结果和不变式。 - 命令测试覆盖不带参数的 `/plan`、`/plan `、激活状态下的 `/plan off`、取消待生效的进入选择、未激活状态下的幂等性、不存在 `/mode` 和 `/review`,以及随 effect 作用域移除。 - 无密钥 TUI 场景通过 `/plan ` 进入、通过 `/plan off` 退出,并证明每个已提交的 `plan/mode` 都先于其所改变的请求头,进入消息在 plan 引导下记录到日志,且退出后的请求不含该引导。 - 完整的 `exit_plan_mode` 评审流程有包测试,但交互式 ACP 场景退役后没有组装应用快照;当前无密钥 TUI 场景只覆盖命令进入和直接退出。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index a6ce4ad6b0..12184063d7 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.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 .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: 2b0add1a916021cfc1790c8f4fd5684305d81be8 -2026-07-28-remove-synthetic-log-only-turns.zh.md: 59fa8e4fcc6301cf6d04cab0d572e0c4ff15731b +2026-07-28-remove-synthetic-log-only-turns.md: 6fe23b0b34c49cc79d912f86e9ffe548b8e08d19 +2026-07-28-remove-synthetic-log-only-turns.zh.md: ccdda3f7606bc160dbb4d896348ed49bd046c6a7 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index 2b0add1a91..6fe23b0b34 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -36,7 +36,7 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2 ## Verification -Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, Code Mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 59fa8e4fcc..ccdda3f760 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 验证 -核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、Code Mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index e65f1cd3b2..4125e5a52e 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.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 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: 674ac9c6a386fc6402d18b06b638ce8209855b15 -2026-07-29-shared-base-config-overlays.zh.md: 6001cfbadb0b8e21528903d64572d1e4d09e9db2 +2026-07-29-shared-base-config-overlays.md: de9dcafeab46c21a4fea85f36d73e3fbc9736d9a +2026-07-29-shared-base-config-overlays.zh.md: 0e5b76df33be433391572f08b639dd85c0f30b8e diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index 674ac9c6a3..de9dcafeab 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -24,13 +24,13 @@ Precedence is list order, last write winning per row: base, then the surface ove A patch replaces its target row's whole `config` rather than merging. Therefore, a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. -The TUI tests live in `apps/cli/tests/`, the Cordis-toolset e2e in `packages/extensions/tool-cordis/tests/`, and the supported Code Mode demo runs `dsh --profile headless` with `DSH_TOOLS_MODE=code`. +The TUI tests live in `apps/cli/tests/`, the Cordis-toolset e2e in `packages/extensions/tool-cordis/tests/`, and the supported PTC mode demo runs `dsh --profile headless` with `DSH_TOOLS_MODE=ptc`. ## Alternatives considered **Leave both trees flat and duplicated.** Rejected: 43 rows maintained twice is the defect, and a gate asserting they stay identical would freeze the duplication rather than remove it. -**Nest the overlays as includes (`code-mode` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. +**Nest the overlays as includes (`ptc` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. **Put the union of all rows in the base and have each overlay disable what it does not want.** Rejected: the base stops meaning "shared", and each surface carries rows it exists only to switch off. @@ -46,7 +46,7 @@ A patch whose `id` matches no row stays a no-op rather than an error. That is de ## Verification -Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. Code Mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application. +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. PTC mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application. All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/boot/app-boot/tests/config-reload.spec.ts`). diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index 6001cfbadb..0e5b76df33 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -24,13 +24,13 @@ Status: implemented patch 会整体替换目标配置项的 `config` 而不合并。因此,取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 -TUI 测试位于 `apps/cli/tests/`,Cordis 工具集 e2e 位于 `packages/extensions/tool-cordis/tests/`,受支持的 Code Mode demo 则以 `DSH_TOOLS_MODE=code` 运行 `dsh --profile headless`。 +TUI 测试位于 `apps/cli/tests/`,Cordis 工具集 e2e 位于 `packages/extensions/tool-cordis/tests/`,受支持的 PTC mode demo 则以 `DSH_TOOLS_MODE=ptc` 运行 `dsh --profile headless`。 ## 备选方案 **保留两棵平铺且重复的树。** 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。 -**把 overlay 嵌套成 include(`code-mode` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 +**把 overlay 嵌套成 include(`ptc` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 **把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。** 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。 @@ -46,7 +46,7 @@ TUI 测试位于 `apps/cli/tests/`,Cordis 工具集 e2e 位于 `packages/exten ## 验证 -组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能完全就绪,且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。Code Mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用。 +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能完全就绪,且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。PTC mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用。 全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/boot/app-boot/tests/config-reload.spec.ts` 覆盖)。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index d98333c159..93a1ed623e 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.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 .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: bfe0d49755abf47314a7b4cf56c738537fca3963 -2026-08-08-copy-only-preset-authoring.zh.md: 71e83d5e17c56e53ce4f4678b5a22baaed02be31 +2026-08-08-copy-only-preset-authoring.md: e6ac66a7a3208b495333166d382e669d13633f51 +2026-08-08-copy-only-preset-authoring.zh.md: da25dcab0374126caea375f5312e063354456d3f diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md index bfe0d49755..e6ac66a7a3 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -10,7 +10,7 @@ The agent-preset settings page carried a web YAML editor: `agentPreset.write` ac ## Decision -Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers). +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `settings/openAgentPresetDirectory { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`; `settings/canOpenAgentPresetDirectory` gates the row, and Settings Controller's `nativeOpen` pins server behavior where platform detection would mislead). ## Consequences @@ -27,4 +27,4 @@ Authoring is a host-side copy, and files are the editor. `agentPreset.write` bec ## Alternatives considered -Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. +Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `session/openWorkspacePath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 71e83d5e17..da25dcab03 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -10,7 +10,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 决策 -创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。 +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称或 `order`。页面包含随附组装的只读查看器、作为唯一创建入口的复制对话框(不提供空白「新建预设」)、自定义行的删除,以及通向文件的位置操作。`settings/openAgentPresetDirectory { agentPreset }` 在 Host 侧解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示;`settings/canOpenAgentPresetDirectory` 控制该行是否显示,Settings Controller 的 `nativeOpen` 则在平台探测可能误判时固定服务端行为。 ## 后果 @@ -27,4 +27,4 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 考虑过的替代方案 -保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 +保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `session/openWorkspacePath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml index 277f33f997..4e47e34616 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.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 .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md -2026-08-10-default-presets-single-editor.md: 82f254079080aeb88d76f4e7cc2c7ab195646ec4 -2026-08-10-default-presets-single-editor.zh.md: 1d89662bcc1acd664e7014a65b54110beb5ffb5c +2026-08-10-default-presets-single-editor.md: 3d1c1dea4f9fcc676a8146033f7785dfd3aaf481 +2026-08-10-default-presets-single-editor.zh.md: 5212156a6a3c11e16165eca04bd141337380e172 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md index 82f2540790..3d1c1dea4f 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md @@ -10,7 +10,7 @@ The `standard`, `code`, and `cordis` presets exposed both the `read`/`write`/`ed ## Decision -The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. Code Mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. +The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. PTC mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. This decision narrows the preset roster rather than removing the tool package or its Python runtime support. The earlier [shared-roster decision](../feature/2026-07-31-even-out-shipped-tool-rosters.md) continues to own why surface-neutral tools live in preset composition; this note owns the editor exception. @@ -22,4 +22,4 @@ This decision narrows the preset roster rather than removing the tool package or ## Consequences -General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the Code Mode SDK, while the minimal assertions continue to pin its presence. +General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the PTC mode SDK, while the minimal assertions continue to pin its presence. diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md index 1d89662bcc..5212156a6a 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 Code Mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 +`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 PTC mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 此决策收窄 preset 工具清单,不移除工具包及其 Python 运行时支持。较早的[共享清单决策](../feature/2026-07-31-even-out-shipped-tool-rosters.zh.md)继续说明与 surface 无关的工具为何归 preset 组合所有;本记录说明编辑器例外。 @@ -22,4 +22,4 @@ Status: implemented ## 后果 -通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 Code Mode SDK 中,同时 minimal 断言继续固定其存在。 +通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 PTC mode SDK 中,同时 minimal 断言继续固定其存在。 diff --git a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.i18n.yaml index 70c3619952..9768def496 100644 --- a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.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 .agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md -2026-08-24-owner-local-profile-tests-and-guides.md: d41bac22bfca907a5601d8fdb279b9a20ecc50d4 -2026-08-24-owner-local-profile-tests-and-guides.zh.md: 87ddfb670ef1375ae48bee36f92f71fd103f461d +2026-08-24-owner-local-profile-tests-and-guides.md: 26a62dd9c28a55d5195ab9a8a56c5369bf521362 +2026-08-24-owner-local-profile-tests-and-guides.zh.md: fa65c09b8b886b1bf442ff5a52976e06032bb75c diff --git a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md index d41bac22bf..26a62dd9c2 100644 --- a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md +++ b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md @@ -14,7 +14,7 @@ There is no top-level `examples/` tree. Named `dsh` profiles are the only Node a Optional user overlays are shipped assets under `apps/cli/config/examples/`, where their bare plugin names resolve through the CLI application manifest. The GitHub review, Schedule, memory MCP, and runtime Cordis guides live under `docs/user/` and link those assets. The runnable Python SDK program and minimal overlay live under `python/sdk/examples/`. -The `demo:acp` and `demo:cordis` scripts are absent. ACP starts through `dsh --profile acp`; the Cordis guide starts `dsh web` with its explicit overlay. `demo:code-mode` remains as a thin wrapper over `dsh --profile headless` with `DSH_TOOLS_MODE=code`. +The `demo:acp` and `demo:cordis` scripts are absent. ACP starts through `dsh --profile acp`; the Cordis guide starts `dsh web` with its explicit overlay. `demo:ptc` remains as a thin wrapper over `dsh --profile headless` with `DSH_TOOLS_MODE=ptc`. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md index 87ddfb670e..fa65c09b8b 100644 --- a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md @@ -14,7 +14,7 @@ Status: implemented 可选用户 overlay 作为交付资产位于 `apps/cli/config/examples/`,其中的裸插件名通过 CLI 应用 manifest 解析。GitHub 评审、Schedule、记忆 MCP 与运行时 Cordis 指南位于 `docs/user/` 并链接这些资产。可运行的 Python SDK 程序与极简 overlay 位于 `python/sdk/examples/`。 -仓库不存在 `demo:acp` 与 `demo:cordis` 脚本。ACP 通过 `dsh --profile acp` 启动;Cordis 指南使用显式 overlay 启动 `dsh web`。`demo:code-mode` 继续作为薄 wrapper,以 `DSH_TOOLS_MODE=code` 运行 `dsh --profile headless`。 +仓库不存在 `demo:acp` 与 `demo:cordis` 脚本。ACP 通过 `dsh --profile acp` 启动;Cordis 指南使用显式 overlay 启动 `dsh web`。`demo:ptc` 继续作为薄 wrapper,以 `DSH_TOOLS_MODE=ptc` 运行 `dsh --profile headless`。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 083463ce10..88dadb758f 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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 .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 580059c6f95583cfb749544c6d4b26cfcb339b39 -2026-07-27-session-projection-and-command-log.zh.md: f4a4e1e5f780af2f20363718b2eb381afd51aff8 +2026-07-27-session-projection-and-command-log.md: 1e0dd435d972cfe35d1433417a3884845e384444 +2026-07-27-session-projection-and-command-log.zh.md: dcb077d87eae02c28714fd752606e0966bc70f94 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 580059c6f9..1e0dd435d9 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -18,9 +18,9 @@ The underlying gap is architectural: the client has no seam for a plugin to obse Four infrastructure pieces, then the domains become pure contributors. -### Deterministic fold and complete wire value +### Whole-value event rule -A projection unit MUST synchronously and deterministically validate and fold the Session events its domain owns. Those durable events may carry complete values or incremental domain transitions; the framework does not prescribe either encoding. When a unit has a client view, `wire.view` MUST publish the complete current value, never a delta. The host therefore remains the sole computation site, and clients can treat each projection frame as final for its seq: higher seq wins, while a later frame repairs a missed one. +A state-carrying log event MUST carry the complete post-change state, never a bare delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one. ### Host projection registry (`dsh-session-projection`, new package) @@ -36,8 +36,8 @@ export interface ProjectionDefinition persist?: boolean // host-only units opt in; client-visible units always persist - /** State before any event is folded, derived from immutable Session metadata. */ - init(header: SessionHeader): S + /** State for the empty log. */ + init(): S /** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */ apply(state: S, event: SessionEvent): S /** Client view; omitted for host-only units. */ @@ -56,15 +56,14 @@ declare module 'cordis' { - `SessionProjectionStateMap` types host fold states; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../implemented/architecture/2026-08-19-session-projection-state-and-client-views.md). - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. -- **Initialization is immutable and follows the event source.** `ProjectionDefinition.init(header)` receives the immutable `SessionHeader` paired with the observed events. Live cells use `session.header`, while cache, history, and detached restores use the header returned by the same persisted read that supplied their events. The registry centrally validates that normalized `header.seedLength ?? 0` does not exceed the observed log; each unit interprets only the creation facts it owns. -- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A valid row may be stale — its `seq` says exactly how stale — while a malformed or mismatched row is discarded and rebuilt from the authoritative log. The one read recipe, cold and live alike: take the usable cached state (or `init(header)`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. +- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. - The package owns `./invariant` (every served key has a live registration). ### Shipped consumer: the subagent identity unit -The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0, header)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision. +The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision. ### Wire: projections block on the history tail page @@ -91,7 +90,7 @@ Because the host is the only computation site, finished values reach clients ove The framework emits it whenever a unit's state reference changes (`Object.is` gate above); `seq` is the unit's watermark at emission. This is live push state, never logged — the same posture as the tool-view `view` slot: replay recomputes on the host. -The client object layer keeps one **generic value store** per session. Partial list hints, exact opening baselines, and whole-value frames follow the [implemented Client merge rules](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md); tentative or stale inputs cannot override an authoritative complete cut. A domain still ships projection support with **zero client code**: there is no `fromEvent`, per-domain cell registration, or client-side domain folding, and the `SessionProjectionMap` merge shares values through the `/types` outlet. The bespoke `session/title` frame and the manager's title-snapshot map retire into this generic pair. +The client object layer keeps one **generic value store** per session: `key → { value, seq }`, seeded by the tail page's projections block and updated by the frame, under the single rule **higher seq wins**. Replayed baselines cannot roll a newer frame back; a lost frame costs staleness until the next frame or baseline, never wrongness. No `fromEvent`, no per-domain cell registration, no client-side domain folding — a domain ships projection support with **zero client code** (the `SessionProjectionMap` merge serves both sides through the `/types` outlet). The bespoke `session/title` frame and the manager's title-snapshot map retire into this generic pair. All the per-domain fences (#587's three layers, #527's write revision) dissolve into the one seq rule. ### Plan through the standard command channel (worked example) @@ -149,7 +148,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **A dedicated `session.projections` RPC** — rejected: baseline-refresh moments coincide exactly with tail-page pulls, so a separate unary buys a second round-trip, a second seq to reconcile, and a client-side "when to refetch" decision that the rider design deletes outright. -**An opaque `get(agent)` provider contract** — rejected: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init(header), apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit. +**An opaque `get(agent)` provider contract** — rejected: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init, apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit. **A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection remains a pure fold with an optional client view. @@ -157,9 +156,9 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **Client-side folding (per-domain projection cells with a `fromEvent`)** — rejected: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code. -**Bounded reverse scan over the log tail (absorber declarations).** Rejected: no implementation supports it, and a bounded suffix cannot generally reconstruct a deterministic fold whose earlier transitions still affect current state. The persisted projection cache covers the cold-read need for both complete-value and incremental domains (cache row plus forward tail replay — the same recipe as the client's baseline and catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve. +**Bounded reverse scan over the log tail (absorber declarations).** Rejected: no implementation supports it, it serves only domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row plus forward tail replay — the same recipe as the client's baseline and catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve. -**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: the host fold already converts either complete-value or incremental events into a complete projection frame. Refetching would duplicate seq coordination and reintroduce a client-side baseline decision; goal's refetch loop, its coalescing, and its stale-read fence all disappear. +**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear. **Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package. @@ -171,23 +170,23 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **Keeping `setPlanMode` as a dedicated RPC** — rejected: plan selection is a user command like any other; the command channel gives it durable recording, flow rendering, multi-tab visibility, and admission semantics without a bespoke wire method. Web UI affordances (a toggle) compose the command line internally. -**Making mutation RPC responses feed cell state** — rejected: the projection frame derived from the committed event arrives immediately and carries the complete current value with a seq; responses feeding state is what required #527's write-revision fence. +**Making mutation RPC responses feed cell state** — rejected: the committed mux event arrives immediately and carries the same whole value with a seq; responses feeding state is what required #527's write-revision fence. ## Acceptance criteria -- A domain plugin ships per-session log-derived state to React by writing only: its durable event declaration, one deterministic host unit with `init(header)`, `apply`, and a complete `wire.view`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side folding code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files. Live and detached folds receive the same immutable header that supplied their events, with the normalized seed boundary centrally validated. +- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files. - The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent. -- Client reconciliation treats list hints as tentative, successful opening baselines as authoritative complete cuts, and live frames as whole-key updates under the [implemented Client merge rules](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md). Stale inputs cannot regress resident state. +- A stale baseline cannot overwrite a newer `session/projection` frame, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths). - A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone. - `useProjection` reaches components through the standard props kit; no hook crosses an inject contract (including `useSelection`). - Session titles ride the generic pair (baseline block + projection frame); the bespoke `session/title` frame and the client title-snapshot map are gone. ## Risks -- **Deterministic fold and complete wire value are load-bearing**: a unit that consults ambient mutable state cannot be rebuilt consistently, and a client delta would force domain folding back into the browser. Mitigation: centralized validation of the normalized seed boundary, the immutable `SessionHeader` passed through the sole `init(header)` call, the pure unit contract, schemas, and complete `wire.view` output keep reconstruction on the host and the client store generic. +- **Whole-value rule is load-bearing**: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition. - **Synchronous unit discipline**: `init`/`apply`/`view` that await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest. - **Live registry churn is not pushed**: loading or unloading a domain plugin mid-session changes the key set, but no session event fires and no frame is pushed; open clients hold the stale key until the next tail pull (reconnect, gap repair, open). Accepted as a dev-only (HMR) staleness window — a registry-change push can be added to the change feed later without contract impact. -- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Non-matching events return the same reference and the count of registered domains is small; if an incremental transition creates a hot path, per-unit event-type prefilters can be added without contract change. +- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change. - **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model. - **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume. - **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first. diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index f4a4e1e5f7..dcb077d87e 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -18,9 +18,9 @@ Status: proposed 先立四件基础设施,之后各领域都退化为纯贡献方。 -### 确定性折叠与完整协议值 +### 全量值事件规则 -投影单元必须同步且确定性地校验并折叠其领域拥有的 Session 事件。这些持久事件可以携带完整值,也可以携带增量式领域转换;框架不规定其中任何一种编码。单元存在客户端视图时,`wire.view` 必须发布完整当前值,绝不能发布增量。host 因而仍是唯一计算地点,客户端可以把每个投影帧视为其 seq 对应的最终结果:seq 较高者胜,后续帧也会修复漏帧。 +携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈:漏掉的更新会被下一次更新纠正。 ### host 侧投影注册表(`dsh-session-projection`,新包) @@ -36,8 +36,8 @@ export interface ProjectionDefinition persist?: boolean // host-only units opt in; client-visible units always persist - /** State before any event is folded, derived from immutable Session metadata. */ - init(header: SessionHeader): S + /** State for the empty log. */ + init(): S /** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */ apply(state: S, event: SessionEvent): S /** Client view; omitted for host-only units. */ @@ -56,15 +56,14 @@ declare module 'cordis' { - `SessionProjectionStateMap` 描述 host 折叠状态;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md)。 - **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 -- **初始化输入不可变,并与事件来源一致。** `ProjectionDefinition.init(header)` 接收与已观察事件配套的不可变 `SessionHeader`。live cell 使用 `session.header`,cache、history 与 detached restore 则使用提供对应事件的同一次持久读取所得 header。注册表集中校验规范化的 `header.seedLength ?? 0` 不得超过已观察日志长度;每个单元只解释自己拥有的创建事实。 -- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。有效行可能陈旧,其 `seq` 精确说明陈旧到哪;畸形或不匹配的行会被丢弃并从权威日志重建。冷读与活读共用同一套读取配方:取可用的缓存状态(或 `init(header)`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 +- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。 ### 已交付的消费方:subagent 身份单元 -注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md)注册了 `subagent` 单元——从 `subagent/descriptor` 按 last-wins 折叠出的持久化 mode/label 身份——`SubagentRuntime.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0, header)` 读取。注册表约定不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 +注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md)注册了 `subagent` 单元——从 `subagent/descriptor` 按 last-wins 折叠出的持久化 mode/label 身份——`SubagentRuntime.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0)` 读取。注册表约定不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 ### 协议层:历史尾页上的 projections 块 @@ -91,7 +90,7 @@ api-proxy 的历史处理器切出尾页后同步遍历注册表——全程没 只要某单元的状态引用发生变化(上文的 `Object.is` 闸门),框架就发出该帧;`seq` 是发出时该单元的水位线。这是实时推送状态,绝不入日志——与 tool-view 的 `view` slot 同一姿态:回放时在 host 重新计算。 -客户端对象层为每个会话维护一个**通用值仓(value store)**。部分 list hint、精确 opening baseline 与完整值 frame 统一遵循[已实现的客户端合并规则](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md);暂存或陈旧输入不能覆盖权威完整切面。领域仍以**零客户端代码**交付投影支持:没有 `fromEvent`、按领域的 cell 注册或客户端侧领域折叠,`SessionProjectionMap` merge 经 `/types` 出口共享值。专设的 `session/title` 帧与 manager 的标题快照表都收编进这对通用机制。 +客户端对象层为每个会话维护一个**通用值仓(value store)**:`key → { value, seq }`,由尾页的 projections 块播种、由该帧更新,唯一规则是 **seq 高者胜**。重放的基线无法把更新的帧往回滚;丢失一个帧的代价只是陈旧——到下一个帧或基线为止——绝不会出错。没有 `fromEvent`,没有按领域的 cell 注册,没有客户端侧领域折叠——领域交付投影支持只需**零客户端代码**(`SessionProjectionMap` merge 经 `/types` 出口同时服务两侧)。专设的 `session/title` 帧与 manager 的标题快照表都收编进这对通用机制。所有按领域自造的栅栏(#587 的三层、#527 的写 revision)都消融进这一条 seq 规则。 ### plan 走标准命令通道(完整示例) @@ -149,7 +148,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq,以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。 -**不透明的 `get(agent)` 提供方约定**——否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init(header), apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。 +**不透明的 `get(agent)` 提供方约定**——否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。 **为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`)**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影继续由纯折叠与可选客户端视图构成。 @@ -157,9 +156,9 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **客户端侧折叠(带 `fromEvent` 的按领域投影 cell)**——否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。 -**对日志尾部的有界反向扫描(absorber 声明)。**不予采纳:现有实现均不支持它,而且有界后缀通常无法重建仍受更早转换影响的确定性折叠。持久投影缓存能统一覆盖完整值领域与增量领域的冷读需求(缓存行加正向尾部回放——与客户端的基线和追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 +**对日志尾部的有界反向扫描(absorber 声明)。**不予采纳:现有实现均不支持它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行加正向尾部回放——与客户端的基线和追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 -**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:host 折叠已经把完整值事件或增量事件转换为完整投影帧。重取会重复 seq 协调,并重新引入客户端侧的基线决策;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。 +**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。 **把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。 @@ -171,23 +170,23 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **保留 `setPlanMode` 专用 RPC**——不予采纳:plan 选择就是一条普通的用户命令;命令通道给它持久记录、flow 渲染、多标签页可见性与准入语义,不需要专设协议方法。Web UI 的交互组件(一个开关)在内部拼出命令行即可。 -**让变更 RPC 的响应喂 cell 状态**——不予采纳:由已提交事件导出的投影帧会立即到达,并携带带 seq 的完整当前值;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。 +**让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。 ## 验收标准 -- 领域插件把按会话的日志派生状态送达 React,只需写:自己的持久事件声明、一个具有 `init(header)`、`apply` 和完整 `wire.view` 的确定性 host 单元、自己那份 `SessionProjectionMap` merge,以及 inject 回调——零客户端侧折叠代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。live 与 detached 折叠接收提供对应事件的同一个不可变 header,规范化 seed 边界由注册表集中校验。 +- 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。 - 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seq;loadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。 -- 客户端合并把 list hint 视为暂存输入,把成功的 opening baseline 视为权威完整切面,并按[已实现的客户端合并规则](../../implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)处理 live frame 的完整 key 更新。陈旧输入不能让驻留状态倒退。 +- 陈旧的基线不能覆盖更新的 `session/projection` 帧,重放的帧也不能让值仓倒退(两条路径都做 seq 高者胜测试)。 - 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。 - `useProjection` 经标准 props 套件抵达组件;没有任何钩子穿过 inject 约定(包括 `useSelection`)。 - 会话标题搭乘这对通用机制(基线块 + 投影帧);专设的 `session/title` 帧与客户端标题快照表彻底移除。 ## 风险 -- **确定性折叠与完整协议值是承重结构**:读取环境可变状态的单元无法得到一致重建,而客户端增量会迫使浏览器重新承担领域折叠。缓解:集中校验规范化的 seed 边界、通过唯一一次 `init(header)` 调用传入不可变的 `SessionHeader`、纯单元约定、schema 与完整 `wire.view` 输出把重建留在 host,并让客户端值仓保持通用。 +- **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元约定让完整状态在每次转移处都是显式的。 - **单元的同步纪律**:`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。 - **注册表的实时增删不做推送**:会话中途加载或卸载领域插件会改变键集,但不会触发任何会话事件、也不会推任何帧;开着的客户端持有陈旧的 key 直到下次尾页拉取(重连、缺口修补、打开)。接受为仅开发期(HMR)的陈旧时窗——日后可以在变更流上加一个注册表变更推送,约定不受影响。 -- **忙碌会话上的主动驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。不匹配的事件返回同一引用,且已注册领域的数量很小;若某项增量转换形成热点路径,可以加按单元的事件类型预过滤,约定不变。 +- **忙碌会话上的主动驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,约定不变。 - **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key,模型本身不用改。 - **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。 - **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价。 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml deleted file mode 100644 index f7a2725d96..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: b63946b581d3a2afcd159e3b1c0ef44f824aa35c -2026-08-10-unary-apiproxy-remote-migration.zh.md: 92f40fc79f2be44855620b6b6915798834284747 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md deleted file mode 100644 index b63946b581..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: Migrate simple unary API Proxy calls to business Remote services - -Status: proposed - -English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md) - -## Problem - -The Host API Proxy still owns many unary methods whose implementation is only service lookup, argument projection, one business call, and response projection. That duplicates the contract across the business Service, API Proxy interface, Zod schemas, route table, client stub, and Client caller even though [Typert Remote calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) already let the business package own this class of call. - -Moving a method mechanically is not sufficient. Agent-bound API Proxy methods call `agentFor()`, which reuses a live Agent, resumes an ordinary cold Session with its recorded preset, deduplicates concurrent resumes, and rejects subagent-owned identities. A Remote method that resolved an `Agent` or `Session` differently would change lifecycle behavior even when the final business call looked identical. - -The API Proxy also contains BFF operations whose contract is not a business method: Session lifecycle and transcript assembly, model-selection state, live-only input control, configuration filtering, skill presentation, Host composition facts, and native desktop operations. Stateful interactions and streams have different lifecycles again. Treating all unary syntax as evidence that a method is simple would move product policy into arbitrary Service packages or force new packages that have no independent business owner. - -Finally, Connection must authenticate a request before choosing the API Proxy fallback or a Typert interceptor. A migration that authenticates only the fallback would let Remote-owned endpoints bypass the browser identity required by every Host operation. - -## Proposal - -Migrate only unary calls whose business operation already has a natural Service owner and whose remaining adaptation is a small parameter or result projection. The Service binds a Typert namespace and decorates an existing method directly with `@Remote` when its signature is the intended consumer contract. A new method is justified only when it performs real adaptation; an identity `remote*` forwarding wrapper is not. - -`@deepseek-ai/dsh-api-remotes/client` will mount each selected business package's generated `/remote` contribution. Client business packages will call `ctx.remote.` and perform Client-owned joins or presentation projection there. The corresponding API Proxy interface member, schema, route, handler, generated client method, fixture implementation, and production invocation will be removed together in that Service's vertical commit. - -Large BFF methods remain in `dsh-host-apiproxy`. A method leaves this migration if implementation discovers endpoint-specific lifecycle policy, substantial orchestration, a Client dependency on a protocol-only error distinction, or a transport shape that cannot be expressed as a small owner-side adapter. - -## Migration set - -| Legacy RPC | Remote destination | Host method | Adaptation | -|---|---|---|---| -| `session.rename` | `ctx.remote.sessionTitle` in `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | Direct `@Remote`; Client maps `eventSeq` to its title projection sequence. | -| `command.list`, `command.execute` | `ctx.remote.commands` in `@deepseek-ai/dsh-commands` | `CommandRuntime.list(Agent)`, `execute(Agent, line, signal)` | Direct `@Remote`; Client maps `undefined` to unmatched and preserves caller cancellation. | -| `llm.providers` | `ctx.remote.llm` in `@deepseek-ai/dsh-llm` | `LlmRuntime.listProviders()`, `listConfigurableProviders()` | Direct `@Remote` on both reads; the Client joins registration and configuration-directory rows. | -| `credentials.describe`, `credentials.set`, `credentials.unset` | `ctx.remote.credentials` in `@deepseek-ai/dsh-api-settings-controller` | `CredentialsController.describe(refs)`, `set(ref, value)`, `unset(ref)` | The controller preserves batch size, reference validation, field projection, provider-absence diagnostics, and provider refusal mapping without adding wire behavior to the abstract Definition. | -| `settings.describe`, `settings.update`, `settings.replace`, `settings.mutate` | `ctx.remote.settings` in `@deepseek-ai/dsh-api-settings-controller` | `SettingsController.describe()`, `update(ns, patch, expectedRevision)`, `replace(ns, section, expectedRevision)`, `mutate(ns, ops, expectedRevision)` | The controller preserves redaction, all three write operations, optimistic revision checks, provider-absence diagnostics, and failure details. | -| `agentPreset.read`, `agentPreset.copy`, `agentPreset.remove` | `ctx.remote.agentPresets` in `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`, `copy(from, id, name?)`, `remove(id)` | `copy` and `remove` are direct; `readDocument` combines stored content with metadata from one live discovery. | -| `subagent.interrupt` | `ctx.remote.subagents` in `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | Adapter constructs the internal user-authority variant without resolving or resuming either Agent. | -| `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | `ctx.remote.workspace` in `@deepseek-ai/dsh-workspace` | `snapshot()`, `insertSessionBefore(workspaceId, sessionId, before?)`, `archiveSession(sessionId)` | Registry adapters detach mutable entities and return the settled workspace or archive snapshot. | - -The Remote API deliberately follows Service names rather than preserving dotted legacy names. For example, Session rename becomes `ctx.remote.sessionTitle.rename(...)`. - -## Deferred API Proxy domains - -| Domain | Methods | Reason retained in the API Proxy | -|---|---|---| -| Session Host lifecycle | `session.list`, `search`, `create`, `fork` | Cross-Agent persistence, Workspace assignment, preset composition, and creation policy. | -| Session transcript | `session.history`, `attachment`, `subagent.history` | Cold/live logs, pagination, projections, presenters, and attachment authorization. | -| Agent model selection | `session.models`, `selectModel` | Per-Agent state, model validation, and default persistence are BFF policy. | -| Agent input and control | `session.prompt`, `updateQueue`, `cancel` | Image admission, Inbox mutation, and endpoint-specific live-only semantics. | -| Native settings document | `settings.openDocument` | Host path resolution, document preparation, and native opening remain product policy in API Proxy. | -| Session skill catalog | `skill.list` | Cold Sessions must not resume; preset standing scope and presenter filtering are BFF joins. | -| Host runtime information | `host.describe` | Version, cwd, default model, and attached count combine several Host owners. | -| Host path opening | `host.openPath`, `agentPreset.openDocument` | Native desktop authority and cancellation belong to the Host composition. | -| Remaining preset, subagent, and workspace calls | `agentPreset.list`, `select`; `subagent.list`, `history`, `prompt`; `workspace.create`, `rename`, `delete` | These calls contain roster policy, live/cold joins, authorization, or serialized multi-operation ordering. | -| Stateful and streaming protocol | approvals, questions, responses, mux and Host streams | They are not one-request/one-result business calls. | - -`workspace.delete` stays with `create` and `rename` because all three participate in the same serialized creation/name/delete chain. Splitting one method out would make the Service and API Proxy observe different operation orders. - -## Agent and Session lookup equivalence - -`createApiRemoteAgentResolver()` constructs one resolver and returns it as the API Proxy's `agentFor`. The same closure is installed through `ctx.typert.lookups.configure('agent', ...)`, `ctx.typert.lookups.configure('session', ...)`, and `ctx.typert.contexts.configureHost('agent', ...)`. Therefore a Remote `Agent` or `Session` parameter and a legacy `agentFor()` call share the same live lookup, in-flight resume table, persistence inspection, preset-aware setup, and ownership fence. - -The migration must pin these outcomes with integration tests: - -- a live ordinary Agent is reused without a resume; -- an ordinary cold Session resumes with its persisted header, events, and recorded preset setup; -- concurrent Agent and Session lookups for one id share one resume; -- a live or cold subagent-owned identity fails with `agent-busy` before business invocation; -- an id missing from durable persistence fails with `session-not-found`; -- resolver failures keep their existing `RpcError` through `TypertLookupFailure`. - -Lookup policy is key-wide, not endpoint-specific. Methods such as prompt, queue editing, cancellation, model selection, and skill listing cannot use the shared `agent` or `session` lookup while retaining live-only or no-resume behavior, so they remain in the API Proxy until Typert supports an explicit per-endpoint policy. - -Methods whose signatures contain only branded ids do not invoke Typert object lookup. `subagents.interruptByParent()` must retain the existing process-local Activation lookup and parent-offline behavior: it does not call `agentFor`, read the catalog, inspect persistence, or cold-resume a parent or child. - -## Client and error behavior - -Generated Remote methods return `RemoteResult` values. Client business services adapt them to their current stores and settle successful results immediately exactly as the existing services do, so event frames remain idempotent replays rather than the only update path. The migration preserves domain validation, provider-absence diagnostics, business error codes, structured details, and successful values; only endpoint addressing, the Remote result envelope, and the separately accepted timeout behavior differ from API Proxy transport. - -Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypertLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure. - -## Browser authentication - -Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Legacy dotted names and Remote slash endpoints therefore use the same process-token-established browser session without an endpoint list. This is a non-escalation requirement: endpoint ownership may change, but an unauthenticated request can reach neither dispatch path. - -## Commit boundaries - -The migration lands as an RFC commit, one vertical commit for each Service, and one final integration commit. A Service commit includes its Host binding and decorators, generated-contract package declarations, API Remotes mount, Client business adoption, and removal of that Service's legacy API Proxy route and production client call. Service commits may be temporarily red because generated artifacts and shared fixtures are reconciled once in the final integration commit. - -The final commit generates every `/remote` artifact from a clean state, updates shared fixtures and tests, moves this note to `implemented`, updates the still-authoritative protocol documentation where central unary ownership changed, and runs the selected repository gates. - -## Alternatives considered - -**Keep simple methods in the central API Proxy.** This preserves one transport facade but continues the duplicated interfaces, schemas, route rows, stubs, and business projections that Typert was introduced to remove. - -**Move every unary API Proxy method.** Unary syntax does not imply single-owner behavior. Session orchestration, live-only control, configuration exposure, and native Host operations would either leak BFF policy into generic Services or create ownerless packages. - -**Give Remote methods a separate resume implementation.** A second resolver could drift on preset restoration, concurrent deduplication, or subagent ownership. Sharing the exact closure with legacy `agentFor()` makes equivalence an implementation fact rather than a promise. - -**Preserve every legacy RPC name and response envelope.** That would turn business packages into copies of the old protocol. Service-oriented names and business values let the Client own joins while Connection continues to own the one RPC envelope. - -**Trust the API Proxy fallback to authenticate requests.** Interceptor selection bypasses that fallback, so Remote methods would become anonymously callable. - -## Acceptance criteria - -- Every migration-table method is callable through its listed `ctx.remote` Service and has no production legacy API Proxy route, schema, map row, client stub, or invocation. -- Existing methods with matching signatures carry `@Remote` directly; every added method performs the adaptation stated in the table and no identity `remote*` wrapper remains. -- Agent/Session integration tests prove the shared lookup outcomes, and subagent interrupt tests prove no cold resume occurs. -- Migrated endpoints reject unauthenticated requests and accept the same valid browser session as legacy endpoints before either dispatch path runs. -- Client behavior and immediate state settlement remain equivalent for every migrated call, including cancellation where supported. -- Deferred methods remain on the API Proxy with their existing behavior. -- A clean generation/build produces and consumes every selected Remote contribution, and focused tests plus final repository gates pass. - -## Risks - -Removing legacy schemas also removes their protocol-specific error taxonomy. A hidden Client branch on one of those codes would make the call non-simple and must be discovered before its Service commit is accepted. - -Generated Remote contracts add build ordering and publication entries to each business package. Missing one runtime mount, declaration export, source-map source, package dependency, or Project Reference can pass a narrow source test while failing a clean Client build. - -Composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass browser authentication. - -This note applies the existing Typert Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md), [browser authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md), and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md deleted file mode 100644 index 92f40fc79f..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: 将简单的一元 API Proxy 调用迁移到业务 Remote 服务 - -Status: proposed - -[English](2026-08-10-unary-apiproxy-remote-migration.md) | 中文 - -## 问题 - -Host API Proxy 仍承载许多一元方法。这些方法的实现仅执行服务查找、参数投影、一次业务调用和响应投影。尽管 [Typert Remote 调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包承载此类调用,这种做法仍会在业务服务、API Proxy 接口、Zod schema、路由表、客户端 stub 和 Client 调用方之间重复定义同一约定。 - -仅机械迁移方法并不足够。与 Agent 绑定的 API Proxy 方法会调用 `agentFor()`:它复用 live Agent,使用普通冷 Session 中记录的 preset 恢复该 Session,对并发恢复去重,并拒绝由 subagent 拥有的 identity。如果 Remote 方法以不同方式解析 `Agent` 或 `Session`,即使最终业务调用看起来相同,也会改变生命周期行为。 - -API Proxy 还包含一些不以业务方法为约定的 BFF 操作:Session 生命周期与 transcript(文本记录)组装、模型选择状态、仅限 live 的输入控制、配置过滤、skill(技能)呈现、Host 组合信息和原生桌面操作。有状态交互与流又具有不同的生命周期。若把一元调用的语法一概视为方法简单的依据,就会把产品策略移入任意服务包,或者迫使系统新增没有独立业务所有者的包。 - -最后,Connection 必须在选择 API Proxy 回退路径或 Typert interceptor 前认证请求。若迁移只在回退路径执行认证,由 Remote 持有的 endpoint 就能绕过每个 Host 操作都要求的浏览器身份。 - -## 提案 - -只迁移符合以下条件的一元调用:其业务操作已经有自然归属的服务,且其余适配只是少量参数或结果投影。当现有方法的签名就是预期的消费方约定时,服务应绑定 Typert namespace,并直接使用 `@Remote` 装饰现有方法。只有执行实质性适配时才有理由新增方法;不得添加只做恒等转发的 `remote*` 包装层。 - -`@deepseek-ai/dsh-api-remotes/client` 将挂载所选各业务包生成的 `/remote` 贡献。Client 业务包将调用 `ctx.remote.`,并在包内执行归 Client 所有的关联或呈现投影。对应的 API Proxy 接口成员、schema、路由、处理程序、生成的客户端方法、fixture(测试前置数据)实现和生产调用点,将在该服务的纵向提交中一并移除。 - -大型 BFF 方法仍留在 `dsh-host-apiproxy` 中。如果实现过程中发现某个方法包含端点特有的生命周期策略、大量编排、Client 依赖仅存在于协议层的错误区分,或者其传输数据结构无法用归属方的小型适配器表达,则该方法不在此次迁移范围内。 - -## 迁移集合 - -| 旧 RPC | Remote 目标 | Host 方法 | 适配 | -|---|---|---|---| -| `session.rename` | `ctx.remote.sessionTitle`,位于 `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | 直接使用 `@Remote`;Client 将 `eventSeq` 映射到自身的标题投影序列。 | -| `command.list`、`command.execute` | `ctx.remote.commands`,位于 `@deepseek-ai/dsh-commands` | `CommandRuntime.list(Agent)`、`execute(Agent, line, signal)` | 直接使用 `@Remote`;Client 将 `undefined` 映射为未匹配结果,并保留调用方的取消行为。 | -| `llm.providers` | `ctx.remote.llm`,位于 `@deepseek-ai/dsh-llm` | `LlmRuntime.listProviders()`、`listConfigurableProviders()` | 两项读取都直接使用 `@Remote`;Client 关联注册行与配置目录行。 | -| `credentials.describe`、`credentials.set`、`credentials.unset` | `ctx.remote.credentials`,位于 `@deepseek-ai/dsh-api-settings-controller` | `CredentialsController.describe(refs)`、`set(ref, value)`、`unset(ref)` | controller 保留批量上限、引用校验、字段投影、provider 缺失诊断与 provider 拒绝映射,不给抽象 Definition 增加 wire 行为。 | -| `settings.describe`、`settings.update`、`settings.replace`、`settings.mutate` | `ctx.remote.settings`,位于 `@deepseek-ai/dsh-api-settings-controller` | `SettingsController.describe()`、`update(ns, patch, expectedRevision)`、`replace(ns, section, expectedRevision)`、`mutate(ns, ops, expectedRevision)` | controller 保留脱敏、三种写入操作、乐观 revision 校验、provider 缺失诊断与失败 details。 | -| `agentPreset.read`、`agentPreset.copy`、`agentPreset.remove` | `ctx.remote.agentPresets`,位于 `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`、`copy(from, id, name?)`、`remove(id)` | `copy` 和 `remove` 直接暴露现有方法;`readDocument` 将存储的内容与一次实时发现取得的元数据组合。 | -| `subagent.interrupt` | `ctx.remote.subagents`,位于 `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | 适配器构造内部的用户权限变体,不解析也不恢复任一 Agent。 | -| `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | `ctx.remote.workspace`,位于 `@deepseek-ai/dsh-workspace` | `snapshot()`、`insertSessionBefore(workspaceId, sessionId, before?)`、`archiveSession(sessionId)` | 注册表适配器分离可变实体,并返回已完成更新的 workspace 或归档快照。 | - -Remote API 有意采用服务名称,而不保留旧 RPC 的点分名称。例如,Session 重命名将变为 `ctx.remote.sessionTitle.rename(...)`。 - -## 暂缓迁移的 API Proxy 领域 - -| 领域 | 方法 | 保留在 API Proxy 中的原因 | -|---|---|---| -| Session Host 生命周期 | `session.list`、`search`、`create`、`fork` | 跨 Agent 持久化、Workspace 分配、preset 组合和创建策略。 | -| Session transcript | `session.history`、`attachment`、`subagent.history` | cold/live 日志、分页、投影、呈现器和附件授权。 | -| Agent 模型选择 | `session.models`、`selectModel` | 各 Agent 的状态、模型校验和默认值持久化属于 BFF 策略。 | -| Agent 输入与控制 | `session.prompt`、`updateQueue`、`cancel` | 图片准入、Inbox 变更和端点特有的仅限 live 语义。 | -| 原生 settings 文档 | `settings.openDocument` | Host 路径解析、文档准备和原生打开仍属于 API Proxy 中的产品策略。 | -| Session skill 目录 | `skill.list` | 不得恢复冷 Session;preset 的常驻 scope 和呈现器过滤属于 BFF 关联操作。 | -| Host 运行时信息 | `host.describe` | 版本、cwd、默认模型和当前已附加的 Session 数量来自多个 Host 所有者。 | -| Host 路径打开 | `host.openPath`、`agentPreset.openDocument` | 原生桌面权限和取消属于 Host 组合。 | -| 其余 preset、subagent 和 workspace 调用 | `agentPreset.list`、`select`;`subagent.list`、`history`、`prompt`;`workspace.create`、`rename`、`delete` | 这些调用包含名单策略、live/cold 关联、授权或多项操作的串行执行顺序。 | -| 有状态协议和流式协议 | 审批、问题、响应、mux 和 Host 流 | 它们不是一次请求/一次结果的业务调用。 | - -`workspace.delete` 与 `create` 和 `rename` 保持在一起,因为三者都参与同一条串行的创建/命名/删除操作链。单独迁出一个方法会使服务与 API Proxy 观察到不同的操作顺序。 - -## Agent 与 Session lookup 等价性 - -`createApiRemoteAgentResolver()` 构造一个 resolver,并将其作为 API Proxy 的 `agentFor` 返回。同一个 closure 通过 `ctx.typert.lookups.configure('agent', ...)`、`ctx.typert.lookups.configure('session', ...)` 和 `ctx.typert.contexts.configureHost('agent', ...)` 安装。因此,Remote `Agent` 或 `Session` 参数与旧版 `agentFor()` 调用共享同一套 live lookup、进行中的恢复表、持久化检查、感知 preset 的 setup 和 ownership fence。 - -迁移必须用集成测试固定以下结果: - -- 直接复用普通的 live Agent,不执行恢复; -- 根据持久化的 header、事件和已记录的 preset setup 恢复普通冷 Session; -- 对同一个 id 并发执行 Agent 与 Session lookup 时,共享同一次恢复; -- 无论 live 还是 cold,由 subagent 拥有的 identity 都会在业务调用前以 `agent-busy` 失败; -- 持久化存储中不存在的 id 以 `session-not-found` 失败; -- resolver 失败会保留现有的 `RpcError`,并通过 `TypertLookupFailure` 传递。 - -Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列编辑、取消、模型选择和 skill 列表等方法如果使用共享 `agent` 或 `session` lookup,就无法保留仅限 live 或禁止恢复的行为,因此在 Typert 支持显式的逐端点策略之前,这些方法仍留在 API Proxy 中。 - -签名只包含 branded id 的方法不会调用 Typert 对象 lookup。`subagents.interruptByParent()` 必须保留现有的进程内 Activation lookup 和父级离线行为:它不会调用 `agentFor`、读取目录、检查持久化,也不会冷恢复父 Agent 或子 Agent。 - -## Client 与错误行为 - -生成的 Remote 方法返回 `RemoteResult` 值。Client 业务服务负责把它们适配到现有 store,并与既有服务一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。迁移保留领域校验、provider 缺失诊断、业务错误码、结构化 details 与成功值;只有 endpoint 寻址、Remote 结果信封和另行接受的超时行为不同于 API Proxy 传输。 - -Resolver 拥有的 `session-not-found` 和 `agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypertLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。 - -## 浏览器认证 - -Connection 在选择 Typert interceptor 或 API Proxy 回退路径前认证完整 `/api` 请求。旧式点分名称和 Remote 斜杠 endpoint 因此无需 endpoint 清单,就能使用同一个由进程令牌建立的浏览器会话。这是一条非提权要求:endpoint 所有权可以变化,但未认证请求不能进入任一分发路径。 - -## 提交边界 - -此次迁移将以一个 RFC 提交、每项服务各一个纵向提交,以及一个最终集成提交落地。服务提交包含其 Host 绑定与装饰器、生成约定所需的包声明、API Remotes 挂载、Client 业务接入,以及移除该服务的旧版 API Proxy 路由和生产客户端调用。服务提交可能暂时无法通过门禁,因为生成产物和共享 fixture 将在最终集成提交中统一调整。 - -最终提交从干净状态生成所有 `/remote` 产物,更新共享 fixture 和测试,将本文移至 `implemented`,更新中央一元调用所有权发生变化之处仍具权威性的协议文档,并运行选定的仓库门禁。 - -## 考虑过的替代方案 - -**将简单方法保留在中央 API Proxy 中。** 这会保留统一的传输外观,但仍会延续 Typert 原本要消除的重复接口、schema、路由行、stub 和业务投影。 - -**迁移每一个一元 API Proxy 方法。** 一元调用形式并不表示行为只有一个所有者。Session 编排、仅限 live 的控制、配置暴露和原生 Host 操作要么会把 BFF 策略泄漏到通用服务中,要么会产生没有所有者的包。 - -**为 Remote 方法提供单独的恢复实现。** 第二个 resolver 可能在 preset 恢复、并发去重或 subagent 所有权方面出现偏差。与旧版 `agentFor()` 共享完全相同的 closure,使等价性成为实现事实,而不只是一项承诺。 - -**保留每一个旧版 RPC 名称和响应 envelope。** 这会使业务包变成旧协议的副本。面向服务的名称和业务值让 Client 负责关联操作,而 Connection 继续负责统一的 RPC envelope。 - -**依赖 API Proxy 回退路径认证请求。** interceptor 选择会绕过该回退路径,使 Remote 方法变成匿名可调用。 - -## 验收标准 - -- 迁移表中的每个方法都可通过表中列出的 `ctx.remote` 服务调用,并且不存在生产环境中的旧版 API Proxy 路由、schema、映射表行、客户端 stub 或调用。 -- 签名匹配的现有方法直接带有 `@Remote`;每个新增方法都执行表中所述的适配,且不保留只做恒等转发的 `remote*` 包装层。 -- Agent/Session 集成测试证明共享 lookup 的各项结果,subagent 中断测试证明不会发生冷恢复。 -- 已迁移 endpoint 拒绝未认证请求,并在任一分发路径运行前接受与旧 endpoint 相同的有效浏览器会话。 -- 每项已迁移调用的 Client 行为和立即提交状态的行为保持等价,包括支持取消之处的取消行为。 -- 暂缓迁移的方法及其现有行为仍保留在 API Proxy 上。 -- 一次从干净状态开始的生成与构建会生成并消费所选的每项 Remote 贡献,且聚焦测试和最终仓库门禁均通过。 - -## 风险 - -移除旧版 schema 也会移除其协议特有的错误分类。如果 Client 中存在依赖其中某个错误码的隐蔽分支,该调用就不是简单调用,必须在接受相应服务提交前发现它。 - -生成的 Remote 约定会为每个业务包引入构建顺序要求和发布条目。如果遗漏运行时挂载、声明导出、source map 来源、包依赖或 Project Reference 中的任何一项,局部源码测试可能仍会通过,但从干净状态开始的 Client 构建会失败。 - -复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的 endpoint 和一个旧版回退 endpoint,确保两条路径都无法绕过浏览器认证。 - -本文应用现有 Typert Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.zh.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)、[浏览器认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)仍具权威性,无需执行归档操作。 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 5c561d9188..b90286f6ba 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.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 .agents/notes/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: dbf73976b606a4a45202c1b29b79f5cfbd77ac1c -2026-08-04-task-surface.zh.md: ecf8764b7b1da7a4a874dd78c6f69df80fb36f36 +2026-08-04-task-surface.md: 15a550e2214660a673ff1b71ec0f38bcb005ee8f +2026-08-04-task-surface.zh.md: 5316a2cdc74ea6caa8e22c32f9af2e03802dd84f diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index dbf73976b6..15a550e221 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -81,7 +81,7 @@ Limits are schema-backed configuration on the Task Surface service. The initial `show_task_surface` accepts `{ model: TaskSurfaceModelV1 }`. The Host parses and normalizes the complete model, rejects the call when that Session already has an open Task Surface, mints `surfaceId`, and returns canonical `{ surfaceId, model }` with the normalized model. `presentationMeta` persists `value.model`, so the projector and executor cannot disagree about normalization. The Native result names the Surface and explains that an ordinary message bypasses it when the client cannot render the panel. The tool then calls `exec.concludeTurn()` so the agent does not continue past the requested human checkpoint. -The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because Code Mode dispatch is nested and cannot carry its presentation metadata to the outer result. +The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `ptc`-only profile does not advertise it because PTC mode dispatch is nested and cannot carry its presentation metadata to the outer result. The browser-safe domain package imports the type-only `Branded` primitive from `@deepseek-ai/dsh-brand` and owns all three Task Surface IDs. The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`: @@ -260,7 +260,7 @@ The implementation depends on the existing message log, canonical tool output, t ## Acceptance criteria -- A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `code`-only mode does not advertise it. +- A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `ptc`-only mode does not advertise it. - The static `TaskSurfaceDock` is the only editor and remains actionable for an active result outside the loaded history window; the keyed toolview remains a read-only transcript summary and replay. A composer takeover hides the still-mounted Dock, preserves its draft, and reveals the same owner after release. - Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact branded occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. - The queued client row retains the correlated message source. `getActive` exposes `queued` or `claiming` across same-process reconnect; commit closes the projection, while explicit discard clears pending state and leaves the Surface open. Queue-row disappearance alone changes no UI state. Edit and steer are rejected, and remove succeeds only before claim. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index ecf8764b7b..5316a2cdc7 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -81,7 +81,7 @@ Task Surface 服务通过受 schema 校验的配置定义限制。初始默认 `show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface,则拒绝调用;否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }`。`presentationMeta` 持久化 `value.model`,使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface,并说明客户端无法渲染面板时,可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。 -工具定义省略 `isConcurrencySafe`。根据现有工具注册表约定,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 +工具定义省略 `isConcurrencySafe`。根据现有工具注册表约定,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `ptc` 的 profile 不会向模型公布该工具,因为 PTC mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 浏览器安全的领域包从 `@deepseek-ai/dsh-brand` 以仅类型方式导入 `Branded` 原语,并拥有全部三个 Task Surface ID。根据[规范工具输出约定](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: @@ -260,7 +260,7 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor ## 验收标准 -- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。 +- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `ptc` 的模式不会向模型公布该工具。 - 静态 `TaskSurfaceDock` 是唯一的编辑器,即使活动结果位于已加载历史窗口之外也仍可操作;带 key 的 toolview 始终是 transcript 的只读摘要和回放。composer 接管会隐藏仍处于挂载状态的 Dock、保留其草稿,并在接管释放后重新显示同一个所有者。 - 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持带品牌类型的确切调用实例关联;关闭操作记录一条日志事件,且不启动轮次。 - 客户端排队行保留已关联的消息来源。`getActive` 可在同一进程的重新连接前后公开 `queued` 或 `claiming`;持久化完成后会关闭投影,显式丢弃则会清除待处理状态并让 Surface 保持打开。队列行消失本身不会改变任何 UI 状态。系统会拒绝编辑和 steering,且移除操作只能在认领前成功。 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml index 98087b0c9b..e9376aed83 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.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 .agents/notes/proposed/process/2026-06-20-discover-package-inventory.md -2026-06-20-discover-package-inventory.md: 7de865e43f87f0e41fad43b3786b9825509e9d50 -2026-06-20-discover-package-inventory.zh.md: fed3d9dd8740b2dc22434f8af7e1e8c025502f61 +2026-06-20-discover-package-inventory.md: 90ad595312431b942f3aac562e4ce8843fb6cc83 +2026-06-20-discover-package-inventory.zh.md: 4c5312f17ddeab1d6457a1a904f921f44e7d0b9d diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md index 7de865e43f..90ad595312 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md @@ -8,7 +8,7 @@ English | [中文](2026-06-20-discover-package-inventory.zh.md) Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. -The [package hierarchy](../../archived/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly the aggregate configs' (`tsconfig.host.json`, `tsconfig.client.json`) project `references`, which TypeScript requires as explicit arrays (no wildcard form). +The [package hierarchy](../../archived/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard — since reverted to one explicit alias per package, generated and gated, because resolving a wildcard's candidates in order dominated source-launch boot ([explicit workspace path aliases](../../implemented/process/2026-08-27-explicit-workspace-path-aliases.md)). What remains is the inventory that cannot be globbed away — chiefly the aggregate configs' (`tsconfig.host.json`, `tsconfig.client.json`) project `references`, which TypeScript requires as explicit arrays (no wildcard form). Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md index fed3d9dd87..4c5312f17d 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -8,7 +8,7 @@ Status: proposed 包与门禁清单在 TypeScript project references、包文档、CI 描述和 Knip 覆盖项中反复出现。大多数只是重述包布局、manifest(元数据清单)数据或聚合命令内容。因此每新增一个包都会产生本可避免的同步点。 -[包层级结构](../../archived/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages//` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单,主要是聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)中的项目引用(`references`)——TypeScript 要求它们是显式数组(没有通配符形式)。 +[包层级结构](../../archived/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages//` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符——该合并此后已被回退为「每包一条、由生成器托管并有门禁把关」的显式别名,因为按顺序试候选的解析开销主导了源码启动时间([显式 workspace 路径别名](../../implemented/process/2026-08-27-explicit-workspace-path-aliases.zh.md))。剩下的是无法用 glob 消除的清单,主要是聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)中的项目引用(`references`)——TypeScript 要求它们是显式数组(没有通配符形式)。 当静态列表编码的是策略时,它们是合理的;当它们只是重复 `package.json`、workspace glob 或包层级结构中已有的 manifest 数据或布局事实时,就是不必要的摩擦。 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml index b84fc76dbd..7027d11d80 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.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 .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md -2026-07-04-prune-dead-core-spine-api.md: ae6ea2d763c1f84b32d1a24bae32f412c77c9976 -2026-07-04-prune-dead-core-spine-api.zh.md: 8e76827de10e119ed1e0d9fbf8a44e8eaf21575c +2026-07-04-prune-dead-core-spine-api.md: ecf4e3aa032e47a26d65edec3817e2a50026da40 +2026-07-04-prune-dead-core-spine-api.zh.md: 81cee28d9f9819802b2276b90a4db51035046e1c diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md index ae6ea2d763..ecf4e3aa03 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md @@ -27,7 +27,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | `CompactionResult.startSeq`, `summarySeq`, `endSeq`, and `summary` | The production consumer reads only shadowed range/seq/token accounting; the durable log owns summary and event identity. | Remove the four result echoes while keeping both shared transcript renderers. | | `BasicCompactionEngine` estimation/summarization visibility | No outside production caller invokes the five methods; the implemented Agent Note names only `estimateContentTokens()` and `summarize()` as subclass hooks. | Make those two `protected` and the three orchestration-only estimators private. | | `CodeLogEntry.source`/`level` and `RunCodeMeta.dispatches` | Every production consumer maps logs to text; no presenter/model path reads the other fields or the persisted dispatch count. | Make code-runtime logs strings (or text-only entries) and remove result-meta dispatch plumbing; keep the local counter that mints deterministic dispatch ids. | -| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while Code Mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | +| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while PTC mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | | `ToolNotFoundError.toolName`, `SystemPrompt.config`, and `BashTask.command` | Each stored public value has no production reader. | Drop the unread field while retaining error messages, resolved configuration behavior, and task lifecycle. | | Backend package-root implementation helpers | The exact inventory below is called only through relative same-package imports. Production namespace imports mount the retained plugin contract without reading these properties; named root consumers are tests. | Retain each adapter/provider/service and its config/error contract; stop exporting the listed helper functions/constants at package roots. | | Consumer package-root implementation helpers | The exact inventory below has only same-package production callers. Production namespace imports mount plugin contracts without reading helper properties; named root consumers are tests. | Retain plugin contracts and stable error codes; move tests to package-local modules or public behavior and stop exporting the listed helpers at package roots. | diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md index 8e76827de1..81cee28d9f 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md @@ -27,7 +27,7 @@ Status: proposed | `CompactionResult.startSeq`、`summarySeq`、`endSeq` 与 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有 summary 和事件标识。 | 移除四个结果回显,保留两个共享的 transcript(文本记录)渲染器。 | | `BasicCompactionEngine` 的估算/摘要方法可见性 | 没有包外生产调用者调用这五个方法;已实现的 Agent Note 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | | `CodeLogEntry.source`/`level` 与 `RunCodeMeta.dispatches` | 每个生产消费方都将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta 的 dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | -| `CodeRuntime.language` 与 `CodeRuntime.isolation` | worker 后端提供唯一的生产值,而 Code Mode 及其他所有生产调用方只调用 `run()`。 | 移除未读描述符,同时保留 worker 的语言、隔离、预算、取消与资源释放行为。 | +| `CodeRuntime.language` 与 `CodeRuntime.isolation` | worker 后端提供唯一的生产值,而 PTC mode 及其他所有生产调用方只调用 `run()`。 | 移除未读描述符,同时保留 worker 的语言、隔离、预算、取消与资源释放行为。 | | `ToolNotFoundError.toolName`、`SystemPrompt.config` 与 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,保留错误消息、已解析的配置行为和任务生命周期。 | | 后端包根实现辅助函数 | 下方精确清单仅通过相对路径的同包导入调用。生产命名空间导入挂载的是保留的插件约定,不读取这些属性;包根命名导入的消费方都是测试。 | 保留每个适配器/提供方/服务及其配置/错误约定;停止在包根导出所列辅助函数/常量。 | | 消费方包根实现辅助函数 | 下方精确清单只有同包生产调用者。生产命名空间导入挂载的是插件约定,不读取辅助属性;包根命名导入的消费方都是测试。 | 保留插件约定和稳定的错误码;将测试迁移到包内模块或公开行为,停止在包根导出所列辅助函数。 | diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.i18n.yaml index 201991e0a3..fdadd105b9 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.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 .agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md -2026-07-12-prune-unused-skill-registry-api.md: c84c6d8d61e13ebe7e1a5d3d29260637e38dbf9f -2026-07-12-prune-unused-skill-registry-api.zh.md: 0fada10b8c086b982dd402adccd42b9e48d129a7 +2026-07-12-prune-unused-skill-registry-api.md: 23fb0163296a1cf6cd8700ad745f60e52371a3b1 +2026-07-12-prune-unused-skill-registry-api.zh.md: e1496aab7bc77dc04600a95bea8a5db3f8955053 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md index c84c6d8d61..23fb016329 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md @@ -21,7 +21,7 @@ Amend the skill-system Agent Note, README, JSDoc, catalogs, and tests. Agent-sco ## Acceptance criteria - Skill collection has one provider-backed path, a cwd-only completed-cache key, and a revision epoch only for in-flight invalidation; retained skill fields have a production reader or a recorded deliberate extension contract. -- Agent-scoped prompt sections, variables, tool providers, tool guards, and structured-output commit behavior in native and Code Mode remain unchanged. +- Agent-scoped prompt sections, variables, tool providers, tool guards, and structured-output commit behavior in native and PTC mode remain unchanged. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. ## Risks diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md index 0fada10b8c..e1496aab7b 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md @@ -21,7 +21,7 @@ skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register( ## 验收标准 - skill 收集只有一条提供方驱动的路径,已完成缓存仅以 cwd 为键,revision epoch 仅用于使进行中的发现操作失效;保留的 skill 字段要么有生产读取方,要么有记录在案的有意扩展约定。 -- agent 作用域的系统提示词段、变量、工具提供方、工具守卫,以及原生模式和 Code Mode 下的 structured-output 提交行为保持不变。 +- agent 作用域的系统提示词段、变量、工具提供方、工具守卫,以及原生模式和 PTC mode 下的 structured-output 提交行为保持不变。 - 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 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 d51608534c..089e243f05 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 .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 14873d3296461357fe6582386f394bc1a5bd9483 -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 4bdac0712972eb3cb85c8e721b39146995401385 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 93a40d2751dfb24778ed77815480effc7d57a1fc +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: c703a53bece7a53c7dc92a17fcccdbd29e9efb49 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 14873d3296..93a40d2751 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 @@ -16,7 +16,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-jsonrpc` for LSP base-protocol framing/correlation** (`lsp-stdio`): the swappable core is ~255 of ~1,800 src lines; the package cannot express the configured `maxMessageBytes` incoming-size bound (restoring it means rebuilding the deleted framing), inverts the cancel-grace teardown semantics (`raceAbort` rejects immediately then tears down; vscode-jsonrpc keeps the promise pending), errors on pre-header stdout banners real servers emit, and is CJS in an ESM-everywhere repo. The [LSP seam note](../../implemented/architecture/2026-07-15-lsp-capability-seam.md) assigns JSON-RPC ownership to `dsh-lsp-stdio`; this audit is the explicit on-record weighing of the dependency it lacked. - **`vscode-languageserver-types` for lsp-stdio'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-sdk-jsonrpc-server`**: 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). +- **`json-rpc-2.0` for `dsh-sdk-jsonrpc-server`**: 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 [archived GUI RPC note](../../archived/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 [archived llm-deepseek dependency decision](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) 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 4bdac07129..c703a53bec 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 @@ -16,7 +16,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-jsonrpc` 承担 LSP 基础协议的分帧/关联**(`lsp-stdio`):可替换的核心只占 src 约 1,800 行中的约 255 行;该包无法表达已配置的 `maxMessageBytes` 入站大小上限(要恢复它就得重建被删掉的分帧代码),反转了取消宽限期的拆除语义(`raceAbort` 立即 reject 再拆除;vscode-jsonrpc 让 promise 保持挂起),会在真实服务器输出的 header 前 stdout 横幅上报错,而且在这个全面采用 ESM 的仓库里它是 CJS。[LSP seam 决策](../../implemented/architecture/2026-07-15-lsp-capability-seam.zh.md)把 JSON-RPC 的所有权划给 `dsh-lsp-stdio`;本次审计正是对该决策当时缺失的这项依赖权衡的明文记录。 - **以 `vscode-languageserver-types` 承担 lsp-stdio 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 -- **以 `json-rpc-2.0` 替换 `dsh-sdk-jsonrpc-server`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 +- **以 `json-rpc-2.0` 替换 `dsh-sdk-jsonrpc-server`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且[已归档的 GUI RPC 决策](../../archived/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 依赖决策](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index d161162ff0..8f500caa86 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -160,7 +160,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - name: Enable Windows Developer Mode (symlink support) if: runner.os == 'Windows' diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index f324900c9e..b86720a5d3 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -87,7 +87,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -131,7 +131,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -176,7 +176,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -264,7 +264,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} # The benchmark's Windows lanes deliberately skip the store cache like # the independent native Windows job; an empty input disables caching. @@ -355,7 +355,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} # Unlike the larger-runner suite, both platforms cache the store here: # the consolidated topology measures cache mechanics as workload. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20fa5fc99c..e7f4ea86e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -109,7 +109,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -173,7 +173,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -261,7 +261,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -329,7 +329,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -421,16 +421,9 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort: a torn-down job on this self-hosted pool can leave a - # locked @reflink native module under the action's install destination, - # and pnpm/action-setup's self-installer then fails its unlink with - # EPERM. Clearing the destination gives every attempt fresh state. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -453,7 +446,7 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '6' DSH_COVERAGE_PARTITIONS: '4' - DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' DSH_GATE_CONCURRENCY: '3' steps: - uses: actions/checkout@v6 @@ -464,13 +457,9 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -502,13 +491,9 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -520,7 +505,7 @@ jobs: run: >- pnpm exec vitest run --no-file-parallelism - --testTimeout 30000 + --testTimeout 90000 packages/shell/tool-pwsh/tests/loader.spec.ts packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts packages/workflow/tool-ralph/tests/integration.spec.ts @@ -548,13 +533,9 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} diff --git a/AGENTS.md b/AGENTS.md index eeb286ccaa..956e28ea45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gate pnpm run test:docs # quick documentation checks (no build; doc-quick aggregate) pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) -pnpm run demo:code-mode -- "task" # headless Code Mode run (needs key) +pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) ``` ### Host sandbox failures @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process boundaries.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). -- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). +- **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Keep comments local.** Do not restate code, explain distant behavior unless locally required, or expand unrelated comments ([rationale](.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. diff --git a/README.i18n.yaml b/README.i18n.yaml index 99b7bfa63e..aca5fead6f 100644 --- a/README.i18n.yaml +++ b/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 -README.md: 8fe2204c765dbccfd79a438a3a58900b7b21f52e -README.zh.md: 3c7ad619303dad747cd5114375647b13a7629f8b +README.md: 9f89db3d4502dea4a0d181799164f304d4976740 +README.zh.md: aa66ef1d24a5e2165859e9337273d807dff3d070 diff --git a/README.md b/README.md index 8fe2204c76..9f89db3d45 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) DeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com). -It is built on an **everything-is-a-plugin** architecture and powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper). +It is built on an **everything-is-a-plugin** architecture and powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://arxiv.org/abs/2608.25512). Documentation: [https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/) diff --git a/README.zh.md b/README.zh.md index 3c7ad61930..aa66ef1d24 100644 --- a/README.zh.md +++ b/README.zh.md @@ -4,7 +4,7 @@ DeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。 -它构建于**一切皆插件**的架构之上,由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。 +它构建于**一切皆插件**的架构之上,由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://arxiv.org/abs/2608.25512)。 文档:[https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 08c7f13f0f..616dcd1909 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -165,7 +165,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | -| [`debug`](https://github.com/visionmedia/debug) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index 5076da6592..8f82b8fdf7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -110,7 +110,6 @@ "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0a9abcfe1b..0a1463fc49 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 0391cb18d89263d6b53319d8b125731178ac5132 -README.zh.md: 13a4873bbd19401991d2a62f50602e133330aa35 +README.md: f5cbe1659e5181cdaa6eaefcdb9bd8c8fe289e6d +README.zh.md: cf040f085241f0af58eb3db485bcedd4316726be diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0391cb18d8..f5cbe1659e 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -30,7 +30,7 @@ The shipped apps own these command lines: | `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol | | `acp` | no options; stdio carries Agent Client Protocol | -A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client, and opens no listening port. +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no browser Connection, HTTP server, Web runtime, or browser client, and opens no listening port. Inspect the composed tree without booting it: @@ -85,13 +85,13 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. +`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `ptc`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting. -Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision. +Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and each recorded feedback uploads the session records not yet shared, through that event; a resumed session shares only its current lifecycle. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 13a4873bbd..cf040f0852 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -30,7 +30,7 @@ | `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 | | `acp` | 无选项;stdio 携带 Agent Client Protocol | -一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。 +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载浏览器 Connection、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: @@ -85,13 +85,13 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`ptc` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。 -会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空的 `DSH_TELEMETRY_DISABLED` 都是具有最终效力的遥测强制关闭开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md)。 +会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,每条已记录的反馈通过该事件上传尚未共享的会话记录;恢复的会话只共享当前生命周期。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ef175ac199..63f58916f2 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -19,6 +19,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +// The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the +// execa deadline, its error text, the outer vitest case budget, and waitForFile all +// share this value so a widening cannot leave a stale 25s diagnostic behind. +const SPAWN_TIMEOUT_MS = 60_000 // The release version, including a prerelease such as 0.0.1-rc.1: `--version` // prints what this manifest carries, so no test may pin it to a literal. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version @@ -36,7 +40,7 @@ async function runBuiltBin( ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', - timeout: 25_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false, env: childEnv, @@ -44,13 +48,13 @@ async function runBuiltBin( ...cwd === undefined ? {} : { cwd }, }) if (result.timedOut) { - throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) } return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } async function waitForFile(file: string): Promise { - const deadline = Date.now() + 20_000 + const deadline = Date.now() + SPAWN_TIMEOUT_MS while (!existsSync(file)) { if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`) await new Promise(resolve => setTimeout(resolve, 20)) @@ -146,6 +150,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', + timeout: SPAWN_TIMEOUT_MS, + killSignal: 'SIGKILL', reject: false, env: { DSH_HOME: fixture.home, @@ -310,7 +316,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { cwd: fixture.home, input: '', reject: false, - timeout: 25_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { DSH_HOME: fixture.home, @@ -335,7 +341,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await runBuiltBin(removed) expect(result.code).toBe(1) } - }, 30_000) + }, SPAWN_TIMEOUT_MS * 3 + 30_000) it('routes help and usage errors without activating startup-dependent rows', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) @@ -392,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, process.platform === 'win32' ? 60_000 : 30_000) + }, SPAWN_TIMEOUT_MS * 3 + 30_000) it('reports SDK startup failure when stdin reaches EOF first', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) @@ -416,14 +422,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-')) const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], { cwd: home, reject: false, - timeout: 25_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { ...process.env, @@ -471,7 +477,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await child rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => { const apiKey = 'built-acp-profile-key' @@ -484,7 +490,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, - timeout: 25_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { ...process.env, @@ -555,7 +561,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await server.close() rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('runs the headless profile through its app-owned task positional', async () => { const apiKey = 'built-dsh-headless-key' @@ -583,7 +589,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await server.close() rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('does not load a project environment for --version', async () => { const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) @@ -606,7 +612,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('uses the launching endpoint and managed credential through the published entry', async () => { const apiKey = 'built-home-layer-key' @@ -646,7 +652,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', rmSync(home, { recursive: true, force: true }) rmSync(project, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('reports a patch-overlay boot failure without hanging', async () => { // The HMR main watcher's initial scan once refreshed the include @@ -666,7 +672,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() @@ -682,7 +688,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => { const fixture = createProfileLifecycleFixture() @@ -734,7 +740,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('hands the app arguments to the profile, which applies them before its rows start', async () => { const fixture = createStartupFixture() @@ -750,7 +756,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { const fixture = createStartupFixture() @@ -764,7 +770,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('keeps the app arguments across a user patch reload', async () => { // A live edit recomposes every row while the provider service remains @@ -798,7 +804,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it("prints the app's own help, starts none of its rows, and exits", async () => { const fixture = createStartupFixture() @@ -811,7 +817,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT @@ -829,7 +835,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], { cwd: checkout, input: '', - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false, env: { DSH_HOME: home }, @@ -860,7 +866,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', rmSync(home, { recursive: true, force: true }) rmSync(checkout, { recursive: true, force: true }) } - }, 90_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) it('activates a dependency that gained dsh.bundle in a later update', async () => { // Reconcile runs against the INSTALLED state on every successful pnpm @@ -897,7 +903,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) describe('config dump', () => { let home: string @@ -913,7 +919,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-base') expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false) - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('prints the headless profile without Host or browser layers', async () => { const { stdout, code, stderr } = await runBuiltBin( @@ -926,7 +932,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('prints the exact standalone sdk-minimal tree without dsh-base', async () => { const { stdout, code, stderr } = await runBuiltBin( @@ -959,7 +965,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') - }, 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. @@ -998,6 +1004,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') - }, 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) }) }) diff --git a/apps/cli/tests/profiles/headless/cordis.yml b/apps/cli/tests/profiles/headless/cordis.yml index 759a252b4e..ea31466a70 100644 --- a/apps/cli/tests/profiles/headless/cordis.yml +++ b/apps/cli/tests/profiles/headless/cordis.yml @@ -124,7 +124,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 diff --git a/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts similarity index 88% rename from apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts rename to apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index ca34a38628..94c83aef42 100644 --- a/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -27,7 +27,7 @@ import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' /** - * With-key Code Mode proof: a real model receives only `run_code`, composes two + * With-key PTC mode proof: a real model receives only `run_code`, composes two * sub-calls, writes a file, and returns curated output while the log records * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. */ @@ -49,12 +49,12 @@ afterEach(async () => { workdir = undefined }) -async function codeModeHarness(cwd: string): Promise { +async function ptcModeHarness(cwd: string): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) await harness.plugin(SystemPrompt, { persona: PERSONA }) - await harness.plugin(ToolRuntime, { mode: 'code' }) + await harness.plugin(ToolRuntime, { mode: 'ptc' }) await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) @@ -66,12 +66,12 @@ async function codeModeHarness(cwd: string): Promise { return harness } -async function workspaceCodeModeHarness(): Promise { +async function workspacePtcModeHarness(): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) await harness.plugin(SystemPrompt, { persona: PERSONA }) - await harness.plugin(ToolRuntime, { mode: 'code' }) + await harness.plugin(ToolRuntime, { mode: 'ptc' }) await harness.plugin(AgentRegistry) await harness.plugin(LocalFileSystem, { cwd: '/' }) await harness.plugin(ToolFs) @@ -85,7 +85,7 @@ async function workspaceCodeModeHarness(): Promise { let keylessCall = 0 const testToolSignal = new AbortController().signal -/** Execute one outer Code Mode call through the real registry and worker. */ +/** Execute one outer PTC mode call through the real registry and worker. */ function runCode( harness: Context, code: string, @@ -112,17 +112,17 @@ function completion(result: ToolExecutionResult): unknown { } /** Keyless real-worker harness for direct typed-binding acceptance tests. */ -async function typedCodeModeHarness(): Promise { +async function typedPtcModeHarness(): Promise { const harness = new Context() await harness.plugin(SystemPrompt) - await harness.plugin(ToolRuntime, { mode: 'code' }) + await harness.plugin(ToolRuntime, { mode: 'ptc' }) await harness.plugin(WorkerThreadCodeRuntime, {}) return harness } /** Keyless real-worker harness with the task-owned bash lifecycle. */ -async function backgroundCodeModeHarness(cwd: string): Promise { - const harness = await typedCodeModeHarness() +async function backgroundPtcModeHarness(cwd: string): Promise { + const harness = await typedPtcModeHarness() await harness.plugin(LocalJobRegistry) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalSubprocessRuntime) @@ -132,9 +132,9 @@ async function backgroundCodeModeHarness(cwd: string): Promise { return harness } -describe('Code Mode typed values: keyless real-worker contracts', () => { +describe('PTC mode typed values: keyless real-worker contracts', () => { it('crosses a large intermediate value intact and exposes only typed tool failure fields', async () => { - ctx = await typedCodeModeHarness() + ctx = await typedPtcModeHarness() ctx.tools.register(defineTool({ name: 'large_value', description: 'Return a large canonical string.', @@ -187,8 +187,8 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }) it('returns a background job id, settles the outer run, and polls that id to completion', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-background-')) - ctx = await backgroundCodeModeHarness(workdir) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-background-')) + ctx = await backgroundPtcModeHarness(workdir) const jobId = completion(await runCode(ctx, ` const started = await tools.bash({ @@ -210,8 +210,8 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }, 15_000) it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-task-cancel-')) - ctx = await backgroundCodeModeHarness(workdir) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-task-cancel-')) + ctx = await backgroundPtcModeHarness(workdir) const pre = new AbortController() pre.abort('pre-aborted') @@ -247,8 +247,8 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }, 15_000) it('keeps foreground bash coupled to the outer signal', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-foreground-cancel-')) - ctx = await backgroundCodeModeHarness(workdir) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-foreground-cancel-')) + ctx = await backgroundPtcModeHarness(workdir) const controller = new AbortController() const startedAt = Date.now() const pending = runCode(ctx, ` @@ -262,20 +262,20 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }, 15_000) it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => { - ctx = await typedCodeModeHarness() + ctx = await typedPtcModeHarness() await ctx.plugin(CordisHostRunner) await ctx.plugin(ToolCordis) const agent = { - id: SessionId('code-mode-cordis'), + id: SessionId('ptc-cordis'), session: { append: vi.fn() }, } as unknown as Agent const value = completion(await runCode(ctx, ` const activeDefinition = await tools.cordis_define({ plugin: { kind: 'new', idPrefix: 'active' }, - name: 'active-code-mode-plugin', + name: 'active-ptc-plugin', purpose: 'prove an active Host half', - code: { host: "return { name: 'active-code-mode-plugin', apply(ctx) {} }" }, + code: { host: "return { name: 'active-ptc-plugin', apply(ctx) {} }" }, }); const active = await tools.cordis_run({ pluginId: activeDefinition.pluginId, @@ -284,9 +284,9 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }); const pendingDefinition = await tools.cordis_define({ plugin: { kind: 'new', idPrefix: 'queue' }, - name: 'pending-code-mode-plugin', + name: 'pending-ptc-plugin', purpose: 'prove a Host half waiting for a Service', - code: { host: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }" }, + code: { host: "return { name: 'pending-ptc-plugin', inject: ['missing-ptc-service'], apply(ctx) {} }" }, }); const pending = await tools.cordis_run({ pluginId: pendingDefinition.pluginId, @@ -329,7 +329,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { packageId: 'pkg-2', pluginRunId: 'run-2', status: 'waiting', - waitingFor: ['missing-code-mode-service'], + waitingFor: ['missing-ptc-service'], }, removed: { pluginId: 'active-1', wasRunning: true }, beforeContainsId: true, @@ -349,11 +349,11 @@ function waitForIdle(harness: Context, agent: Agent): Promise { }) } -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a program over real tools', () => { +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a program over real tools', () => { it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) - ctx = await codeModeHarness(workdir) - const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-e2e-')) + ctx = await ptcModeHarness(workdir) + const agent = ctx.agentLoop.create(SessionId('e2e-ptc'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ @@ -396,14 +396,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p }, 180_000) it('projects nested workspace instructions discovered by an fs sub-call', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-')) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-workspace-e2e-')) await mkdir(join(workdir, '.git'), { recursive: true }) await mkdir(join(workdir, 'pkg/deep'), { recursive: true }) - await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the Code Mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`) + await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the PTC mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`) await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n') - ctx = await workspaceCodeModeHarness() + ctx = await workspacePtcModeHarness() const handle = await ctx.agents.create({ - sessionId: SessionId('e2e-code-mode-workspace-session'), + sessionId: SessionId('e2e-ptc-workspace-session'), meta: { cwd: workdir }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, }) @@ -411,7 +411,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p handle.agent.followup(createUserMessage({ content: [{ type: 'text', - text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', + text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: PTC mode workspace handshake?', }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 7829b67adc..7ef63f67a5 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.reasoning_effort).toBe('max') expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') + expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models') child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4747543ec0..a08ef12d8a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -84,6 +84,9 @@ async function bootWeb( { id: 'skill-badge', disabled: false }, { id: 'modules', disabled: true }, { id: 'connection', disabled: true }, + // Export owns a Connection Fetch route, so this Host-only composition + // disables it with the transport service above. + { id: 'session-log-download', disabled: true }, // The always-on reload chain waits for the browser roster and bound port // disabled above. { id: 'client-hmr', disabled: true }, @@ -213,7 +216,7 @@ describe('the shipped Web composition', () => { it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -241,13 +244,19 @@ describe('the shipped Web composition', () => { } }) - it('applies the default-off subagent model-selection preference only to new sessions', async () => { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + it('applies the default-off subagent model allowlist only to new sessions', async () => { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: false, + allowedModels: [], + }) const disabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-disabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }], + }) const enabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-enabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), @@ -341,18 +350,18 @@ describe('the shipped Web composition', () => { } }) - it('presents `code` as Code Mode without disturbing a native session beside it', async () => { + it('presents `ptc` as PTC mode without disturbing a native session beside it', async () => { const coded = await ctx.agents.create({ - sessionId: SessionId('preset-code'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined), + sessionId: SessionId('preset-ptc'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'ptc').then(() => undefined), }) const native = await ctx.agents.create({ - sessionId: SessionId('preset-code-native'), + sessionId: SessionId('preset-ptc-native'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) try { // One tool reaches the MODEL: the transport. The registry's catalog for - // this agent is unchanged — a code mode collapses the presentation, not + // this agent is unchanged — PTC mode collapses the presentation, not // the capabilities — so the assembly is what carries the claim. const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) @@ -939,7 +948,7 @@ describe('a composition that configures its own preset roots', () => { ]) const listed = await rootsCtx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard', 'team-spec']) expect(listed.every(preset => preset.broken === undefined)).toBe(true) // The shipped root comes first: a configured directory claiming a shipped // id is shadowed, never the other way around. diff --git a/apps/cli/tests/web-auth.e2e.ts b/apps/cli/tests/web-auth.e2e.ts index b22f611306..05889c8d32 100644 --- a/apps/cli/tests/web-auth.e2e.ts +++ b/apps/cli/tests/web-auth.e2e.ts @@ -119,19 +119,19 @@ async function stopWeb(running: RunningWeb): Promise { clearTimeout(forced) } -/** POST one real API Proxy envelope while controlling the wire Host header. */ -function describeHost(port: number, host: string, cookie?: string): Promise { +/** POST one real Remote envelope while controlling the wire Host header. */ +function describeSettings(port: number, host: string, cookie?: string): Promise { const body = JSON.stringify({ type: 'client-request', rpcId: 'web-auth-real-cli', - method: 'host.describe', - payload: {}, + method: 'settings/describe', + payload: { args: {} }, }) return new Promise((resolve, reject) => { const req = httpRequest({ hostname: '127.0.0.1', port, - path: '/api/host.describe', + path: '/api/settings/describe', method: 'POST', headers: { host, @@ -165,7 +165,7 @@ describe('dsh web authentication through the real CLI', () => { expect(firstUrl.pathname).toBe('/') expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u) - expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({ + expect(await describeSettings(port, `localhost:${String(port)}`)).toEqual({ status: 401, body: 'unauthorized', }) @@ -180,13 +180,13 @@ describe('dsh web authentication through the real CLI', () => { expect(setCookie).not.toContain('Secure') const cookie = setCookie.split(';', 1)[0]! - const authenticated = await describeHost(port, firstUrl.host, cookie) + const authenticated = await describeSettings(port, firstUrl.host, cookie) expect(authenticated.status).toBe(200) const authenticatedBody = JSON.parse(authenticated.body) as unknown expect(authenticatedBody).toMatchObject({ type: 'server-response', rpcId: 'web-auth-real-cli', - result: { ok: true, value: { version: expect.any(String) as unknown } }, + result: { ok: true, value: { namespaces: expect.any(Array) as unknown } }, }) await stopWeb(first) @@ -194,7 +194,7 @@ describe('dsh web authentication through the real CLI', () => { second = await startWeb(root, dshHome, port) const secondUrl = new URL(second.launchUrl) expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token')) - expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200) + expect((await describeSettings(port, secondUrl.host, cookie)).status).toBe(200) const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777 expect(credentialMode).toBe(0o600) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index f94ce582a6..5c9045e441 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -104,7 +104,7 @@ describe('the shipped shell composition (real bundle layers)', () => { describe('shipped agent presets gate both shell tools by platform', () => { const presetRoot = SHIPPED_PRESET_ROOT - it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { + it.each(['standard', 'ptc', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { const entries: unknown = yaml.load( readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'), { schema: entryListSchema }, diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 135838240c..7b0a769721 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../packages/bundle/web-app" }, - { - "path": "../../packages/host/apiproxy" - }, { "path": "../../packages/host/webserver" }, diff --git a/apps/web/package.json b/apps/web/package.json index eeb6693e6b..26554dc5d2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 3c260eab76..ef09bb3c70 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/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 apps/web/tests/README.md -README.md: 2104d9422cfbbcbc7ffc4b12e491c0a62daa3b9d -README.zh.md: 4dfa5b2f61c757e481d9b8e012b37a5e71d75093 +README.md: e5b97f0b7527da01ce7c926360145fee49e168f0 +README.zh.md: f360c2b487bbf5b1a1c81492e5b4c2d4eaa8d749 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 2104d9422c..e5b97f0b75 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -11,8 +11,8 @@ the deliberate composition divergences from `dsh web` — are documented in ## These are Host-face tests They type-check in the root `tsconfig.host.json`, not in the Client aggregate, -because they read Host services directly: `ctx.apiProxy`, the Host -`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does +because they read Host services directly: `ctx.connection`, the Host +`SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 4dfa5b2f61..f360c2b487 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -10,7 +10,7 @@ ## 这些是 Host 面的测试 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 -Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore`、`ctx.sessionProjectionCache`。运行时驱动 +Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动 浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml index 6644752bc2..3791809a7e 100644 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -1,12 +1,13 @@ # The authoring lane drives the location affordance. A real desktop open # would pop a file manager on the machine running the tests and the # capability itself is platform-detected (macOS yes, headless Linux CI no), -# so the gateway is pinned headless: `hasDocument` is false everywhere and +# so both native-open owners are pinned headless: `hasDocument` is false everywhere and # `openDocument` answers the directory as text — the same branch on every # host, and the one whose rendering a golden can hold. A patch replaces the # row's complete config, so the shipped routing defaults ride along. -- id: api-gateway +- id: session-controller + config: + nativeOpen: false +- id: settings-controller config: - provider: deepseek-official - model: deepseek-v4-flash nativeOpen: false diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 05bfb45a4b..a18893d82b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,6 +1,6 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built // workspace `lib/client.js` artifacts booted through AppWebEntry's -// ModuleLoader path (loadBundle) against the keyless FixtureApiClient +// ModuleLoader path (loadBundle) against the keyless fixture Connection RPC // transport. Every file that mounts this graph needs the same boot entry list, // the same bundle map, the same jsdom globals, and the same mount call, and // differs only in what it asserts afterwards, so the scaffolding lives here. diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index cbd5f294a4..275cda21d5 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -4,7 +4,7 @@ // reach a surface only the built bundles expose; this one asserts that the // graph assembles at all — staged activation across the immediately tier and // the inject layers, per-plugin CSS injection, and a rendered journey reaching -// chat content from the keyless FixtureApiClient transport. +// chat content from the keyless fixture Connection RPC. // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index fc01177397..8e4a5769ef 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -18,7 +18,9 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot, +} from './support.ts' const MODE = webSnapshotMode() const TURN_COUNT = 12 @@ -315,6 +317,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`) await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1) expect(await toolRow.textContent()).toContain(spec.toolResultMarker) + await expandOwningTurnProcess(page, toolRow) const disclosure = toolRow.locator('[data-sample="bash"]') expect(await disclosure.getAttribute('aria-expanded')).toBe('false') await disclosure.click() @@ -332,9 +335,10 @@ describe('web e2e: continuous conversation grown through the composer', () => { ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) - await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), { - timeout: 10_000, - }).toBe(1) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1) + expect(await page.locator( + '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + ).count()).toBe(0) expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000) expect(sessionEvents.filter(event => ( event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 9a04a5c3d3..5e3c02a436 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -19,7 +19,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts' +import { conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const SESSION_ID = 'chat-long-interactions-e2e' @@ -278,6 +278,7 @@ describe('web e2e: long Chat interaction contract', () => { const summary1 = call1.locator('[data-sample="bash"]') const summary2 = call2.locator('[data-sample="bash"]') + await expandOwningTurnProcess(page, call2) expect(await summary1.getAttribute('aria-expanded')).toBe('false') expect(await summary2.getAttribute('aria-expanded')).toBe('false') await summary2.focus() diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index e7b1fedfb9..2664ef0f01 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -20,7 +20,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' @@ -355,7 +355,7 @@ async function wheelUntilVisible(page: Page, selector: string, deltaY: number): function visibleFlowAnchor(page: Page): Promise { return page.locator('[data-conversation-scroll]').evaluate((host) => { - const rows = [...host.querySelectorAll('[data-chat-anchor-key]')] + const rows = [...host.querySelectorAll('[data-chat-anchor-key]:not([hidden])')] const viewport = host.getBoundingClientRect() const composer = host.querySelector('[data-composer-seat]') const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom @@ -431,12 +431,19 @@ async function expectMarkerAboveComposer(page: Page, marker: string): Promise { await wheelToHistoryStart(page) const older = page.getByRole('button', { name: 'Load earlier', exact: true }) + const loading = page.getByRole('button', { name: 'Loading…', exact: true }) await older.waitFor({ timeout: 10_000 }) const anchor = await visibleFlowAnchor(page) const before = await loadedFlowRows(page) await older.click() - await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before) + await expect.poll(async () => ( + await loadedFlowRows(page) > before && await loading.count() === 0 + ), { timeout: 30_000 }).toBe(true) await nextPaint(page) + if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) { + expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0) + return + } await expectSameFlowTop(page, anchor) } @@ -614,6 +621,7 @@ describe('web e2e: long Chat scroll contract', () => { const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]` const liveRow = world.page.locator(liveRowSelector) + await expandOwningTurnProcess(world.page, liveRow) await wheelUntilVisible(world.page, liveRowSelector, -300) const toolAnchor = await liveRow.evaluate((row) => { const flow = row.closest('[data-chat-anchor-key]') @@ -762,6 +770,7 @@ describe('web e2e: long Chat scroll contract', () => { const lastToolRow = world.page.locator( `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`, ) + await expandOwningTurnProcess(world.page, lastToolRow) await lastToolRow.focus() await world.page.keyboard.press('End') await expectBottom(world.page) diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index cc0b7a01a2..47fb91069b 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // The command image-attachment envelope over the BUILT client graph (real -// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter +// bundles via AppWebEntry, keyless fixture Connection RPC): an enter // submission carrying composer images resolves only through a command whose // descriptor declares `input.images`. A non-declaring command refuses with // one composer error banner and everything retained; a declaring command @@ -21,7 +21,7 @@ async function freshComposer(): Promise { fireEvent.click(start) return await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts index a4905b86e0..ee8c9a07df 100644 --- a/apps/web/tests/composer-tab-geometry.e2e.ts +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -130,7 +130,7 @@ function measureTab(page: Page): Promise { async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise { await page.getByRole('tab', { name: tab, exact: true }).click() if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) - else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) + else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]:visible').first().waitFor({ timeout: 30_000 }) // Both measurements are taken after a paint, so a rectangle read mid-transition // cannot be reported as a shift the cascade did not cause. await page.evaluate(() => new Promise((settle) => { diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 9e378902fc..84babd4b61 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -18,7 +18,7 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url)) @@ -162,6 +162,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { .toBeGreaterThanOrEqual(1) const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first() + await expandOwningTurnProcess(page, inspectRow) await inspectRow.waitFor({ timeout: 10_000 }) // cordis_define does NOT go through the generic row: ui-cordis registers a @@ -169,6 +170,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { // title here is the CARD's ("Cordis Plugin"), and the expanded body is the // card's own two code sections rather than a generic args dump. const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first() + await expandOwningTurnProcess(page, defineRow) await defineRow.waitFor({ timeout: 10_000 }) // The whole summary row is the expand toggle (unified tool-row interaction). await defineRow.locator('[aria-expanded]').first().click() @@ -177,10 +179,12 @@ describe('web e2e: Cordis tools use their owned cards', () => { await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE) const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first() + await expandOwningTurnProcess(page, runRow) await runRow.waitFor({ timeout: 10_000 }) await expect.poll(() => runRow.textContent()).toContain('snap-') const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first() + await expandOwningTurnProcess(page, stopRow) await stopRow.waitFor({ timeout: 10_000 }) await expect.poll(() => stopRow.textContent()).toContain('snap-') await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok') diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index d82f48d8c1..8827af14ad 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -33,8 +33,8 @@ - text: 复制 - listitem: - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - - code: code + - text: PTC 模式 内置 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 + - code: ptc - 'button "查看: PTC 模式"': - img - text: 查看 diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index 0869bd7f3f..ed0ec29cc9 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -33,8 +33,8 @@ - text: 复制 - listitem: - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - - code: code + - text: PTC 模式 内置 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 + - code: ptc - 'button "查看: PTC 模式"': - img - text: 查看 diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index c8d983cb34..ec0e49aece 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -33,8 +33,8 @@ - text: 复制 - listitem: - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - - code: code + - text: PTC 模式 内置 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 + - code: ptc - 'button "查看: PTC 模式"': - img - text: 查看 diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 4633f83fde..d0a80be760 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -2,7 +2,7 @@ - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." + - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md new file mode 100644 index 0000000000..1459a2aa8a --- /dev/null +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -0,0 +1,56 @@ +- tree "Sessions": + - treeitem "{{workspace}}" [expanded]: + - img + - text: {{workspace}} + - treeitem "Review deepseek-harness/deepseek-harness#314 Session actions for Review deepseek-harness/deepseek-harness#314" [selected]: + - text: Review deepseek-harness/deepseek-harness#314 + - button "Session actions for Review deepseek-harness/deepseek-harness#314": + - img + +--- + +- banner: + - navigation "Session hierarchy": + - button "Review deepseek-harness/deepseek-harness#314" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection webhook github webhook handled by review-pr-when-ready": + - img + - img + - text: Context injection webhook github webhook handled by review-pr-when-ready +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: "Review complete: no actionable findings." +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Read Only"': Read Only +- button "Select model, current github-webhook-review-test/reply": + - text: github-webhook-review-test/reply + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 738a1b11a3..c4f9670888 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -24,14 +24,9 @@ - img - img - text: System prompt -- button "Context injection webhook github webhook handled by review-pr-when-ready": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection webhook github webhook handled by review-pr-when-ready -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: "Review complete: no actionable findings." - button "Copy": - img @@ -42,7 +37,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index 42811d29ae..c10eef6aac 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -14,7 +14,7 @@ - img - img - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index adae2f723e..65c73793b2 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -45,7 +45,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 85c537bb18..3c6a9475f8 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -24,7 +24,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index d940beabc9..228443ee1e 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -36,7 +36,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 5561c3574e..e063215b77 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -40,7 +40,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 6ac6d76796..3c3be0922c 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - status: 已保存 minimax-cn。 - list: - listitem: diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 2f7d4a3a30..1d538bcfe4 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index bb129bf2ea..df48328fd3 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index dfeb637b5f..54bf1db3c3 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list - text: 提供方 - combobox "提供方": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 020f70d095..a302932e65 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 73c66388f3..496443b057 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 54ac42b4a7..08e89d18f8 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -32,6 +32,10 @@ - 'button "展开设置: Agent 循环"': - text: Agent 循环 Agent 如何派发工具调用。 - img + - listitem: + - 'button "展开设置: Subagent"': + - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 + - img - listitem: - 'button "展开设置: 网页搜索"': - text: 网页搜索 DeepSeek 搜索提供方。 diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index 6668011066..ea65a91653 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -1,9 +1,10 @@ - listbox "Trigger suggestions": - text: Files & folders - - option "Folder · folderx/ folderx Browse folder" [selected]: - - text: Folder · folderx/ folderx - - button "Browse folder": › - - option "File · reference.txt reference.txt" - - text: Session conversations - - option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" - - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" + - option "folderx/ Browse folder" [selected]: + - text: folderx/ + - button "Browse folder": + - img + - option "reference.txt" + - text: Sessions + - option "reference-order-target-session {{cwd}} · {{age}}" + - option "reference-source-session {{cwd}} · {{age}}" diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 75543314b2..7cec2eaf08 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -13,7 +13,7 @@ - button "Session recall Research notes": - img - text: Session recall Research notes -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 7e9a1518ba..10f4e568fe 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -44,7 +44,11 @@ - img - button "Decrease font size": - img - - text: px Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - text: px Conversation display Controls process content in completed turns + - button "Compact": + - text: Compact + - img + - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior - button "Queue": - text: Queue - img diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index c884703ca6..1aa949a86d 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -44,7 +44,11 @@ - img - button "减小字号": - img - - text: px 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 + - text: px 对话显示 控制已完成轮次的过程内容 + - button "Compact": + - text: Compact + - img + - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送": - text: 排队发送 - img diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md new file mode 100644 index 0000000000..8a1e8287a7 --- /dev/null +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -0,0 +1,49 @@ +- banner: + - navigation "Session hierarchy": + - button "/user-invoke-demo and confirm the fixtur" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index b06b3aae82..dea9369875 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -16,14 +16,9 @@ - text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Context injection user-invoke-demo": - - img - - img - - text: Context injection user-invoke-demo - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img @@ -34,7 +29,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 1722b90b13..dc2200a0b3 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -375,7 +375,7 @@ - text: 7/25 {{clock}} Ran for {{duration}} - button "Back to bottom": - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c201520e8d..c3847df394 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -31,7 +31,7 @@ - text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md new file mode 100644 index 0000000000..39c0dc68da --- /dev/null +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -0,0 +1,59 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "Ask question 1/1 answered": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" +- button "Copy": + - img +- paragraph: "Got it: BANANA and ORANGE." +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 885143a0f6..f17b2e14c7 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -16,18 +16,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call": + - text: 1 tool call - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": - - img - - img - - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. -- button "Ask question 1/1 answered": - - img - - img - - text: Ask question 1/1 answered - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img @@ -44,7 +35,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index ac92f5da0c..957fabf4a3 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -15,7 +15,8 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -23,6 +24,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md') const MODE = webSnapshotMode() // Discard port: loopback listener never binds, so FULL telemetry discloses // the shipped default policy without any record reaching a collector. @@ -91,12 +93,20 @@ describe('web e2e: /feedback command acknowledgement', () => { expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'ack.expected.md', 'ack-expanded.expected.md', + ]) }) }) diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts new file mode 100644 index 0000000000..f60e504160 --- /dev/null +++ b/apps/web/tests/feedback-release.e2e.ts @@ -0,0 +1,145 @@ +// Keyless assembled-browser coverage for the shipped FEEDBACK_ONLY default +// over the Web bundles and the real host wire. The scaffold mounts the +// shipped telemetry row in FEEDBACK_ONLY mode against this suite's own +// loopback mock collector, so the default release path is real: /feedback +// releases the session records through that event (exactly one OTLP request, +// carrying the drive prompt and the feedback text), the acknowledgement pins +// the feedback-gated disclosure sentence, and a second feedback releases only +// the records since the first handoff — the earlier prompt does not repeat. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url)) +// The release path needs only a settled ordinary turn, so this lane replays +// the feedback-command scenario's recorded session (declared as this +// manifest's `session.source`) instead of recording a duplicate. +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url)) +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md') +const MODE = webSnapshotMode() + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: feedback-gated release under the shipped default mode', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let collector: Server + const uploads: string[] = [] + + beforeAll(async () => { + collector = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + const raw = Buffer.concat(chunks) + uploads.push((request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw).toString()) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + collector.listen(0, '127.0.0.1') + await once(collector, 'listening') + const address = collector.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + scaffold = await launchWebScaffold({ + telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`, + telemetryMode: 'FEEDBACK_ONLY', + // The replayed session.jsonl belongs to the feedback-command scenario; + // comparing (or refreshing) the persisted session here would rewrite + // that shared source with this lane's feedback events. The release + // evidence lives in this lane's golden and collector assertions. + compareReplaySession: false, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + collector?.close() + collector?.closeAllConnections() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-drive')) + if (MODE !== 'record') { + // Drift guard: the shared fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('[data-composer-input]').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + // Re-records the SHARED feedback-command session this lane replays. + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + + it.skipIf(MODE === 'record')('releases the session records through the feedback and pins the disclosure', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release')) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(uploads).toEqual([]) + const input = page.locator('[data-composer-input]').first() + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/recording feedback uploads the session records not yet shared/).count()).toBe(1) + + // FEEDBACK_ONLY releases through the committed feedback event: exactly + // one request reaches the collector, carrying the whole unshared range. + await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(1) + expect(uploads[0]).toContain('the diff view is unreadable') + expect(uploads[0]).toContain(PROMPT) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('releases only the records since the last handoff on a second feedback', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix')) + const input = page.locator('[data-composer-input]').first() + await input.fill('/feedback the second remark') + await input.press('Enter') + await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(2) + // Suffix semantics: the second release starts after the first feedback's + // handoff, so the drive prompt already shared must not repeat. + expect(uploads[1]).toContain('the second remark') + expect(uploads[1]).not.toContain(PROMPT) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md']) + }) +}) diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 4d473ddf13..1530c35c80 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -11,6 +11,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-webhook' import { + captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, @@ -23,6 +24,9 @@ import { saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/github-review/cordis.yml', import.meta.url)) const EXPECTED = fileURLToPath(new URL('./expected/github-ready-review/conversation.expected.md', import.meta.url)) +const EXPANDED_EXPECTED = fileURLToPath( + new URL('./expected/github-ready-review/conversation-expanded.expected.md', import.meta.url), +) const PROVIDER = 'github-webhook-review-test' const MODEL = 'reply' const SECRET = 'github-webhook-review-secret' @@ -157,6 +161,12 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => { const tree = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd) const conversation = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EXPECTED, `${tree}\n\n---\n\n${conversation}`, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(EXPANDED_EXPECTED, `${tree}\n\n---\n\n${expanded}`, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 06bc22a74b..f9cd46b60c 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -1,5 +1,5 @@ // Keyless assembled-browser coverage for the goal bar over the shipped Web -// bundles and FixtureApiClient wire. The command creates a real projected +// bundles and the fixture Connection RPC. The command creates a real projected // goal in the fixture session; the golden pins the active strip, while the // clear gesture proves the acknowledged tombstone leaves neither stale chrome // nor a duplicate-mutation error. @@ -45,7 +45,7 @@ describe('web e2e: goal bar clear convergence', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear')) // Startup reuses the fixture workspace's blank session, keeping this // command independent of alpha's running replay and pending question. - const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') + const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') await input.waitFor({ timeout: 10_000 }) await input.fill('/goal guard rapid clear clicks') await input.press('Enter') diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 18596f923c..e901035fe7 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -11,7 +11,7 @@ import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-goal' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -20,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-tu const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md') const MODE = webSnapshotMode() const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的' @@ -29,7 +30,7 @@ const PACKAGE_FILES: Readonly> = { 'packages/client/ui-conversation/README.md': '# UI conversation\n', 'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n', 'packages/client/ui-conversation/src/client.ts': 'export {}\n', - 'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n', + 'packages/client/ui-chat/tests/chat-view.client.spec.tsx': 'export {}\n', 'packages/context/session-reference/README.md': '# Session reference\n', 'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n', 'packages/context/session-reference/src/index.ts': 'export {}\n', @@ -153,9 +154,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(goalRounds(sessionEvents)).toEqual([1, 2]) expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) - await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), { - timeout: 15_000, - }).toBe(2) + await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2) + expect(await page.locator( + '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + ).count()).toBe(0) const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled')))) @@ -163,11 +166,19 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = await branchButtons.last().focus() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold!.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 140_000) it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'replay.override.json', 'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', + ]) }) }) diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 6c8107d1bd..3b5f8d00f2 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Multimodal image surfaces over the BUILT client graph (the ptc-fixture +// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized @@ -90,7 +90,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // this assembled lane pins the intake chain over the built graph. const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface @@ -165,7 +165,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov fireEvent.click(start) const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface @@ -220,7 +220,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index d8714e9a91..1a01d0c4f9 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -18,7 +18,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' @@ -33,6 +34,7 @@ const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') +const RELOADED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded-expanded.expected.md') const MODE = webSnapshotMode() const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' @@ -239,6 +241,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () // must render the same settled transcript the live turn produced. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(RELOADED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) }, 90_000) @@ -277,7 +285,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', + 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', + 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 5fd28cd394..53c84ba989 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -23,7 +23,8 @@ import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -34,10 +35,12 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // state, and the other four capture what remains after cancel, after a // non-retryable failure, after retry recovery, and after retry exhaustion. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') +const CANCEL_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'cancel-expanded.expected.md') const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') +const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md') const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md') const MODE = webSnapshotMode() const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid' @@ -185,6 +188,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // partial ('partial' is the hang entry's replayed prefix) and no more. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold!.workspaceCwd, + ) + await compareOrRefreshGolden(CANCEL_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) @@ -268,6 +277,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // while the settled retry row remains as durable recovery context. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold!.workspaceCwd, + ) + await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) @@ -307,8 +322,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md', - 'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md', + 'session.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md', + 'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md', + 'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md', ]) }) }) diff --git a/apps/web/tests/max-tokens-notice.expected.e2e.ts b/apps/web/tests/max-tokens-notice.expected.e2e.ts index 3e35d13bb8..5634315a32 100644 --- a/apps/web/tests/max-tokens-notice.expected.e2e.ts +++ b/apps/web/tests/max-tokens-notice.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless FixtureApiClient transport, opens the fixture session, and pins the +// keyless fixture Connection RPC, opens the fixture session, and pins the // surface its max-tokens turn (72) reaches — the turn-end notice row that a // provider output-cap truncation must render instead of ending silently. // diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index ececcbd5bf..b63e155d9d 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -154,6 +154,12 @@ describe('minimal agent preset', () => { await sessionRow.click() await page.getByText('MINIMAL_PRESET_REQUEST_OK', { exact: true }).waitFor({ timeout: 15_000 }) + const process = page.locator('[data-turn-process]') + await process.waitFor({ timeout: 15_000 }) + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false') + await process.click() + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true') + const row = page.locator('[data-sample="bash"]').first() await row.waitFor({ timeout: 15_000 }) await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index ffc1084d9e..d5ecdabc70 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -19,7 +19,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.jsonl') @@ -390,6 +390,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await ensureSeedOpen(page) const bashRow = page.locator('[data-sample="bash"]').first() + await expandOwningTurnProcess(page, bashRow) await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') @@ -404,11 +405,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { ok: true, value: { opened: true as const } }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockResolvedValue({ opened: true }) try { await fileLink.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') @@ -425,6 +423,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Expanded, the recorded command's own output sits in the message flow, // derived from the logged call/result presentations alone. const bashRow = page.locator('[data-sample="bash"]').first() + await expandOwningTurnProcess(page, bashRow) await bashRow.waitFor({ timeout: 15_000 }) if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 6e1f410fba..3258604b35 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -15,7 +15,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -27,6 +28,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') +const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md') const MODE = webSnapshotMode() // One command line: /plan enters plan mode and submits the rest as the turn's @@ -111,13 +113,20 @@ describe('web e2e: plan review takeover round trip', () => { await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(APPROVED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md', + 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', + 'approved.expected.md', 'approved-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 0235f687de..9d7982d7d8 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -76,7 +76,9 @@ describe('web e2e: plugin configuration section', () => { const dialog = await openPlugins() // Every card the shipped web composition exposes: the shell executor, the - // agent loop, and the DeepSeek search provider. + // agent loop, subagent selection, and the DeepSeek search provider. + await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -88,6 +90,45 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('persists selected adapter routes as the subagent model allowlist', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) + const dialog = await openPlugins() + await dialog.getByText('Subagent', { exact: true }).click() + const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' }) + + await toggle.click() + const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' }) + await models.waitFor({ timeout: 10_000 }) + const firstModel = models.getByRole('checkbox').first() + await firstModel.check() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + + const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' }) + await expandSubagent.waitFor({ timeout: 5_000 }) + await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('enabled: true') + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') + await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) + expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) + + await toggle.click() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expandSubagent.waitFor({ timeout: 5_000 }) + await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('stages an edit and writes it only when saved', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) const dialog = await openPlugins() @@ -109,6 +150,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) .toBe(true) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() // Presence in the user layer is what the badge reports, and the reset is // offered only for a field that has one. await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) @@ -167,6 +211,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) .toBe(false) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() expect(await timeout.inputValue()).toBe('60000') expect(await dialog.getByText('已覆盖').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 9ffb31a2ae..76d71f7fb7 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -310,17 +310,13 @@ async function bootPreview(origin: string, browser: Browser): Promise { const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) await configureLater.click() - await page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 30_000 }) const exercised = await page.evaluate(async () => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } - interface PreviewApi { - skills: { list(payload: { sessionId: string }): Promise> } - } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise - createApiClient(): PreviewApi } const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: PreviewTransport }).__DSH_TRANSPORT__ if (transport === undefined) throw new Error('preview transport is absent after boot') @@ -352,14 +348,15 @@ async function bootPreview(origin: string, browser: Browser): Promise { if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) return body.result.value } - const api = transport.createApiClient() - const skills = await api.skills.list({ sessionId }) - if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) + const skills = await remote<{ skills: Array<{ name: string }> }>( + 'skills/list', { request: { sessionId } }, + ) const createDirectory = async (path: string, name: string): Promise => { await remote('directoryPicker/createDirectory', { path, name }) await new Promise((resolve) => { setTimeout(resolve, 250) }) - const refreshed = await api.skills.list({ sessionId }) - if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) + await remote<{ skills: Array<{ name: string }> }>( + 'skills/list', { request: { sessionId } }, + ) } await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created') // Settings and credentials both answer over the Remote carrier, so this @@ -383,7 +380,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { - skillCount: skills.result.value.skills.length, + skillCount: skills.skills.length, credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } }) diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 81259cf365..0761639de5 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -149,19 +149,16 @@ describe('web e2e: a finished turn ends with the files it produced', () => { expect(await showFolder.count()).toBe(1) expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { ok: true, value: { opened: true as const } }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockResolvedValue({ opened: true }) try { const [response] = await Promise.all([ - page.waitForResponse(response => new URL(response.url()).pathname === '/api/host.openPath'), + page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/openWorkspacePath'), showFolder.click({ clickCount: 1 }), ]) expect(response.status()).toBe(200) expect(openPath).toHaveBeenCalledTimes(1) - expect(openPath.mock.calls[0]![0].payload).toEqual({ path: `${scaffold.workspaceCwd}/.` }) + expect(openPath.mock.calls[0]![0]).toMatchObject({ path: `${scaffold.workspaceCwd}/.` }) } finally { openPath.mockRestore() } diff --git a/apps/web/tests/produced-files.overlay.yml b/apps/web/tests/produced-files.overlay.yml index 0afe201f71..4267b6d2cf 100644 --- a/apps/web/tests/produced-files.overlay.yml +++ b/apps/web/tests/produced-files.overlay.yml @@ -1,6 +1,9 @@ # The summary test asserts the native-folder action without launching it. Pin # the capability so headless Linux CI and desktop developer hosts expose the # same UI branch; platform opener behavior belongs to the Host unit tests. -- id: api-gateway +- id: session-controller + config: + nativeOpen: true +- id: settings-controller config: nativeOpen: true diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts similarity index 82% rename from apps/web/tests/code-mode-round.e2e.ts rename to apps/web/tests/ptc-round.e2e.ts index 5f83566065..1098f6c203 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -1,4 +1,4 @@ -// Code Mode browser round trip with nested sub-calls and details selection. +// PTC mode browser round trip with nested sub-calls and details selection. // Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' @@ -8,20 +8,20 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/session.jsonl', import.meta.url)) -const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/ui.expected.md', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt ' + 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.' -describe('web e2e: Code Mode round renders nested sub-calls', () => { +describe('web e2e: PTC mode round renders nested sub-calls', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -30,7 +30,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - toolsMode: 'code', + toolsMode: 'ptc', compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) @@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) it('drives the recorded prompt to a settled turn (all modes)', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-drive')) if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } @@ -89,11 +89,12 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) it.skipIf(MODE === 'record')('renders the code parent row with always-visible nested sub-rows', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-rows')) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // The parent run_code row wears the code variant with the model-authored // description as its summary (the presentCall contract). const codeRow = page.locator('[data-variant="code"]').first() + await expandOwningTurnProcess(page, codeRow) await codeRow.waitFor({ timeout: 10_000 }) const nest = page.locator('[data-subcalls]').first() await nest.waitFor({ timeout: 10_000 }) @@ -102,17 +103,22 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }, 60_000) it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel closed', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-details')) const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') + await expandOwningTurnProcess(page, nest) await nest.locator('[data-sample="bash"]').first().click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') }) - it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria')) - const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-aria')) + const snapshot = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 4dff77b911..29fbf1e146 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -17,9 +17,11 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -29,7 +31,10 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // Final golden: the answered transcript — the question resolved into its tool // round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') +const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md') +const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md') const MODE = webSnapshotMode() +const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e' // The composer's own growth cap, in text lines (QuestionComposer.module.css // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants @@ -61,6 +66,57 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: // collapsed row painting its copy outside its own box. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Replace the successful Tool settlement and omit the answer-dependent model step. */ +function cancelledFixture(fixture: string): string { + let replaced = false + const lines: string[] = [] + for (const line of fixture.trimEnd().split('\n')) { + const event: unknown = JSON.parse(line) + if (!isRecord(event)) throw new Error('question fixture event is invalid') + if (event.type === 'session') { + // Keep the derived session's relative-time header stable as the source + // fixture ages. + event.createdAt = Date.now() + lines.push(JSON.stringify(event)) + continue + } + if (replaced) { + const data = event.data + if ((event.type === 'step/end' && isRecord(data) && data.step === 1) + || event.type === 'turn/end') lines.push(line) + continue + } + if (event.type !== 'tool/result') { + lines.push(line) + continue + } + const data = event.data + if (!isRecord(data)) throw new Error('question fixture tool/result data is invalid') + const message = data.message + if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) { + throw new Error('question fixture tool/result message is invalid') + } + message.content[0].content = [{ + type: 'text', + text: 'Error: the user cancelled ask_user_question', + }] + message.content[0].isError = true + data.error = { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + } + replaced = true + lines.push(JSON.stringify(event)) + } + if (!replaced) throw new Error('question fixture has no tool/result event') + return `${lines.join('\n')}\n` +} + describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold let browser: Browser @@ -236,10 +292,20 @@ describe('web e2e: resident question composer round trip', () => { expect(await page.locator('[data-question-key]').count()).toBe(0) expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) - // Golden of the answered transcript: the ask_user_question round trip - // rendered as history (question tool row + DONE), composer takeover gone. + // The default golden pins Compact mode before process disclosure. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) + // Keep the ask_user_question card's readable answer in the expanded golden + // even though Compact mode hides the process by default. + await expandTurnProcesses(page) + const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true }) + await answeredRow.click() + await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1) + expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1) + expect(await page.getByText(/"answers"/).count()).toBe(0) + const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) @@ -285,13 +351,73 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) - it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { +}) + +describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { + let cancelledScaffold: WebScaffold + let cancelledBrowser: Browser + let cancelledPage: Page + let cancelledTripwire: ReturnType + + beforeAll(async () => { + cancelledScaffold = await launchWebScaffold({}) + await seedSession( + cancelledScaffold, + cancelledFixture(await readFile(FIXTURE, 'utf8')), + CANCELLED_SEED_ID, + ) + cancelledBrowser = await chromium.launch() + cancelledPage = await newEnglishPage(cancelledBrowser) + cancelledTripwire = watchConsole(cancelledPage) + await cancelledPage.goto(cancelledScaffold.authenticatedUrl, { waitUntil: 'load' }) + await cancelledPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = cancelledPage.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = cancelledPage.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + }, 120_000) + + afterAll(async () => { + await cancelledBrowser?.close() + await cancelledScaffold?.close() + }) + + it('expands to the cancellation verdict and original questions', async () => { + onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row')) + const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true }) + await row.waitFor({ timeout: 15_000 }) + await row.click() + + await cancelledPage + .getByText('This question set was cancelled before answers were submitted.', { exact: true }) + .waitFor({ timeout: 10_000 }) + await cancelledPage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await cancelledPage.getByText(/"questions"/).count()).toBe(0) + expect(await cancelledPage + .getByText('Error: the user cancelled ask_user_question', { exact: true }).count()).toBe(0) + + const snapshot = (await captureStableAria( + cancelledPage, + '[class*="centerCol"]', + cancelledScaffold.workspaceCwd, + )).split(CANCELLED_SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE) + expect(cancelledTripwire.pageErrors).toEqual([]) + expect(cancelledTripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', + 'cancelled.expected.md', + 'answered-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 637b542084..c20e1e396f 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, onTestFailed } from 'vitest' import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -24,6 +24,7 @@ const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md') const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md') +const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -168,6 +169,12 @@ describe('web e2e: queue row actions', () => { const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(PRESERVED_EXPANDED_EXPECTED, expanded, MODE) const settled = scaffold.whenTurnSettled() await input.fill(WAKE) @@ -272,7 +279,10 @@ describe('web e2e: queue row actions', () => { it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'], + [ + 'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', + 'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md', + ], ) }) }) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 3f170b6558..ad7e5410da 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -147,17 +147,26 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await input.fill('@') await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) - const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + // Session rows are dated from the live Host list, so their age bucket + // advances while the suite runs. + const snapshot = await captureStableAria( + page, '[role="listbox"]', scaffold.workspaceCwd, { normalizeAge: true }, + ) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Files & folders') - expect(snapshot).toContain('Session conversations') + expect(snapshot).toContain('Sessions') expect(snapshot).not.toContain('text: reference Files & folders') - expect(snapshot).toContain('File \u00b7 reference.txt') - expect(snapshot).toContain('Session \u00b7 Research notes') + expect(snapshot).toContain('reference.txt') + // A seed reaches disk as a log alone, and the Host labels a session from + // its projections: no checkpoint, so the row is its id. The fixture's own + // title (`Research notes`) is unreachable here by construction, and the + // package suite owns the titled paths. + expect(snapshot).toContain(SOURCE_SESSION_ID) + expect(snapshot).not.toContain('Research notes') expect(snapshot).not.toContain('text: Subagents') await input.fill('@reference') - await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() // The pick lands an atomic chip: a real DOM capsule carrying the domain // icon and the label (the canonical reference text lives on the node and // expands on submit; the surface text is the label plus the separator). @@ -166,12 +175,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => fileReference.locator('svg').count()).toBe(1) await expect.poll(() => input.textContent()).toBe('reference.txt ') - await input.fill('@Research') - await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() + await input.fill('@reference-source') + await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click() const sessionReference = page.locator('[data-composer-chip]').last() - await expect.poll(() => sessionReference.textContent()).toBe('Research notes') + await expect.poll(() => sessionReference.textContent()).toBe(SOURCE_SESSION_ID) await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) - await expect.poll(() => input.textContent()).toBe('Research notes ') + await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} `) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -183,7 +192,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@reference') - await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) // The #2813 gesture: collapse the caret to the document start, directly @@ -191,16 +200,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await input.click() await page.keyboard.press('ControlOrMeta+A') await page.keyboard.press('ArrowLeft') - await page.keyboard.type('@Research') - await menu.getByRole('option', { name: /Session · Research notes/ }).click() + await page.keyboard.type('@reference-source') + await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click() // Both chips survive the boundary insert: the session chip lands ahead of // the intact file chip. const chips = input.locator('[data-composer-chip]') await expect.poll(() => chips.count()).toBe(2) - await expect.poll(() => chips.first().textContent()).toBe('Research notes') + await expect.poll(() => chips.first().textContent()).toBe(SOURCE_SESSION_ID) await expect.poll(() => chips.last().textContent()).toBe('reference.txt') - await expect.poll(() => input.textContent()).toBe('Research notes reference.txt ') + await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} reference.txt `) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -212,7 +221,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@reference') - await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) // First ArrowLeft crosses the trailing space; the second steps across the @@ -250,7 +259,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through // as an atomic chip — folder glyph, no trigger character, one unit. await writeComposerDraft(page, input, '@folderx') // First folder query on this page: allow the Host index a cold start. - await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor({ timeout: 60_000 }) + await menu.getByRole('option', { name: /^folderx\// }).waitFor({ timeout: 60_000 }) await page.keyboard.press('Enter') const chip = input.locator('[data-composer-chip]').last() await expect.poll(() => chip.textContent()).toBe('folderx/') @@ -260,18 +269,54 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through // Tab drills: the literal descent text stays editable and the open menu // lists the folder's children. await writeComposerDraft(page, input, '@folderx') - await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor() + await menu.getByRole('option', { name: /^folderx\// }).waitFor() await page.keyboard.press('Tab') await expect.poll(() => input.textContent()).toBe('@folderx/') - await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() // The row chevron drills the same way by pointer. await writeComposerDraft(page, input, '@folderx') - const row = menu.getByRole('option', { name: /Folder · folderx\// }) + const row = menu.getByRole('option', { name: /^folderx\// }) await row.waitFor() await row.getByRole('button', { name: 'Browse folder' }).click() await expect.poll(() => input.textContent()).toBe('@folderx/') - await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() + await page.keyboard.press('Escape') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('a drilled listing carries a breadcrumb back to the workspace root', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-breadcrumb')) + const input = page.locator('[data-composer-input]').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + const crumbs = page.getByRole('navigation', { name: 'Folder navigation' }) + + // A path the user typed carries its own context: no header. + await writeComposerDraft(page, input, '@folderx/') + await menu.getByRole('option', { name: /child\.txt/ }).waitFor({ timeout: 60_000 }) + await expect.poll(() => crumbs.count()).toBe(0) + + // The same listing reached by drilling owes the user the way back. + await writeComposerDraft(page, input, '@folderx') + await menu.getByRole('option', { name: /^folderx\// }).waitFor() + await page.keyboard.press('Tab') + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() + await crumbs.waitFor() + await expect.poll(() => crumbs.getByRole('button').allTextContents()) + .toEqual(['Workspace', 'folderx']) + // The listed folder is where the menu already is: its crumb is inert, and + // the rows drop the location the header now carries. + await expect.poll(() => crumbs.getByRole('button', { name: 'folderx' }).isDisabled()).toBe(true) + await expect.poll(() => menu.getByRole('option', { name: /child\.txt/ }).textContent()) + .toBe('child.txt') + + // Clicking the root crumb rewrites the token back to a bare trigger. + await crumbs.getByRole('button', { name: 'Workspace' }).click() + await expect.poll(() => input.textContent()).toBe('@') + await expect.poll(() => crumbs.count()).toBe(0) + await menu.getByRole('option', { name: /^folderx\// }).waitFor() await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 7413576885..a4899f5e57 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -1,5 +1,5 @@ // Web e2e scenario: fresh round trip. A real chromium types a prompt into the -// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo +// real composer; the wire, Remote gateway, agent loop, and the REAL bash tool (echo // in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless) // or the live adapter (record). Drive steps run in every mode and wait only // on generic completion (whenTurnSettled — never model-content selectors, so @@ -17,14 +17,21 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) +const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) +const UI_EXPANDED_EXPECTED = fileURLToPath( + new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url), +) const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -72,7 +79,21 @@ describe('web e2e: fresh round trip through the real assembly', () => { // Arm the host-side settled barrier BEFORE the send click. const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) - await input.press('Enter') + const echoSnapshot = await input.evaluate(async (element, prompt) => { + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, + })) + // sendSession registered its paint yield first. This frame observes the + // committed echo while admission remains queued on the following task. + await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) + const echo = document.querySelector('[data-submission-echo]') + return [ + `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, + `composer: ${JSON.stringify(element.textContent ?? '')}`, + `contenteditable: ${element.getAttribute('contenteditable')}`, + ].join('\n') + }, PROMPT) + await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) const sessionId = await settled settledSessionId = sessionId if (MODE === 'record') { @@ -148,10 +169,17 @@ describe('web e2e: fresh round trip through the real assembly', () => { }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) - it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => { + it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt')) + await expandTurnProcesses(page) const disclosure = page.getByRole('button', { name: 'System prompt', exact: true }) const body = page.locator('[data-system-prompt-body]') await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1) @@ -172,9 +200,10 @@ describe('web e2e: fresh round trip through the real assembly', () => { it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) // Interaction over the REAL wire-delivered transcript (the fixture-client - // tier pins the same gesture against FixtureApiClient; this one runs on + // tier pins the same gesture against the fixture Connection RPC; this one runs on // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. + await expandTurnProcesses(page) const think = page.getByRole('button', { name: /^Think/ }).first() expect(await think.getAttribute('aria-expanded')).toBe('false') await think.click() @@ -188,10 +217,12 @@ describe('web e2e: fresh round trip through the real assembly', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', + 'submission-echo.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'web-context.expected.md', 'ui.expected.md', + 'ui-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index c504913b9b..585d10ab98 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -42,7 +42,7 @@ it('isolates replay skill discovery from every ambient host root', async () => { const ctx = scaffold.ctx // Local skill discovery belongs to the agent's preset LAYER of the host // registry, so the roots under test are only reachable through a composed - // agent's view — the same scope the gateway's `skill.list` resolves for a + // agent's view — the same scope the `skills/list` Remote resolves for a // browser request about a session. const handle = await ctx.agents.create({ sessionId: SessionId('hermetic-skills'), diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6e89812da7..6a98024ec1 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -289,7 +289,7 @@ export interface LaunchOptions { * yml default. The code runtime row is always in the tree, so no extra * insertion is needed. */ - toolsMode?: 'native' | 'code' | 'both' + toolsMode?: 'native' | 'ptc' | 'both' /** * Insert the opt-in model-facing Cordis tool provider into the shipped tree. * Record and replay use the same tool surface, so captured request headers @@ -329,12 +329,14 @@ export interface LaunchOptions { default: string } /** - * Mount the shipped telemetry row in FULL mode against this exporter URL - * instead of disabling it. Used to pin a real backend disclosure in - * assembled coverage; point the URL at a local dead endpoint so no record - * leaves the process. + * Mount the shipped telemetry row against this exporter URL instead of + * disabling it. Used to pin a real backend disclosure in assembled + * coverage; point the URL at a local endpoint (a dead port, or a scenario's + * own mock collector) so no record leaves the machine. */ telemetryUrl?: string + /** Uploading mode for the mounted telemetry row. Defaults to `FULL`. */ + telemetryMode?: 'FULL' | 'FEEDBACK_ONLY' /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -497,7 +499,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { document.body.setAttribute('data-ds-dark-theme', '') }) + await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) const openSidebar = page.getByRole('button', { name: 'Open sidebar' }) if (await openSidebar.isVisible()) { await openSidebar.click() @@ -691,6 +691,17 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await page.getByRole('button', { name: 'Clear search' }).click() await catalogRow.waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: 'View options' }).click() + await page.getByRole('menuitem', { name: 'In one list' }).click() + const flatRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) + await flatRow.waitFor({ timeout: 15_000 }) + expect(await flatRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) + + await page.getByRole('button', { name: 'View options' }).click() + await page.getByRole('menuitem', { name: 'WorkSpace' }).click() + await catalogRow.waitFor({ timeout: 15_000 }) + expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) + await openSession(page, CATALOG_TITLE) const parentAgent = await liveAgent(scaffold, CATALOG_SESSION_ID) @@ -700,7 +711,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { const catalog = page.getByRole('list', { name: 'Active reminders' }) await catalog.waitFor({ timeout: 10_000 }) expect(await catalog.getByRole('listitem').count()).toBe(3) - const layout = await catalog.evaluate((element) => { + const lightLayout = await catalog.evaluate((element) => { const box = element.getBoundingClientRect() return { width: box.width, @@ -710,10 +721,24 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { background: getComputedStyle(element).backgroundColor, } }) - expect(layout.width).toBe(336) - expect(layout.right).toBeLessThanOrEqual(layout.viewport) - expect(layout.scrollWidth).toBeLessThanOrEqual(layout.viewport) - expect(layout.background).not.toBe('rgba(0, 0, 0, 0)') + expect(lightLayout.width).toBe(336) + expect(lightLayout.right).toBeLessThanOrEqual(lightLayout.viewport) + expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport) + expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)') + const longPrompt = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) + .locator(':scope > span').nth(1) + const promptLayout = await longPrompt.evaluate(element => ({ + height: element.getBoundingClientRect().height, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })) + expect(promptLayout.height).toBeGreaterThan(18) + expect(promptLayout.scrollWidth).toBeLessThanOrEqual(promptLayout.clientWidth) + + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor) + expect(darkBackground).not.toBe('rgba(0, 0, 0, 0)') + expect(darkBackground).not.toBe(lightLayout.background) await compareOrRefreshGolden( CATALOG_EXPECTED, await captureStableAria(page, '[aria-label="Active reminders"]', scaffold.workspaceCwd), @@ -758,6 +783,11 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(field.left).toBeGreaterThanOrEqual(metadataLayout.left) expect(field.right).toBeLessThanOrEqual(metadataLayout.right) } + const scrollLayout = await catalog.evaluate(element => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })) + expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) const sessionRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) expect(await sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) diff --git a/apps/web/tests/search-card.expected.e2e.ts b/apps/web/tests/search-card.expected.e2e.ts index ec8da8ff86..b54d14e8e7 100644 --- a/apps/web/tests/search-card.expected.e2e.ts +++ b/apps/web/tests/search-card.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless -// FixtureApiClient transport (no API key, no model round), opens the fixture +// fixture Connection RPC (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // intentionally carries no behavior assertions; this is the assembled-output check diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 3349eb17b5..368beeb9e1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -22,15 +22,19 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeter } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url)) +const UI_EXPANDED_EXPECTED = fileURLToPath( + new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url), +) // Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url)) const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url)) @@ -278,6 +282,14 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { timeout: 10_000, }).toBe(1) + const process = page.locator('[data-turn-process="1"]') + await process.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await process.getAttribute('aria-expanded')).toBe('false') + const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom) + const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => + element.getBoundingClientRect().top) + // Collapsed control row keeps its own 8px margin plus the 8px flow gap. + expect(answerTop).toBe(processBottom + 16) expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). @@ -331,6 +343,12 @@ describe('web e2e: seeded history renders through cold resume', () => { const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = (await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + )).split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { @@ -395,14 +413,12 @@ describe('web e2e: seeded history renders through cold resume', () => { // file links (not expand-in-place / not details). Runs after the golden // capture; still zero model calls. const fileLink = page.locator('[data-variant="read"] button').first() + await expandOwningTurnProcess(page, fileLink) await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { ok: true, value: { opened: true as const } }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockResolvedValue({ opened: true }) try { await fileLink.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') @@ -417,14 +433,8 @@ describe('web e2e: seeded history renders through cold resume', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { - ok: false as const, - error: { code: 'internal', message: 'xdg-open is not available', details: {} }, - }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockRejectedValue(new Error('xdg-open is not available')) try { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) @@ -435,7 +445,7 @@ describe('web e2e: seeded history renders through cold resume', () => { .toContain('path open failed: xdg-open is not available') await page.getByRole('button', { name: 'Retry' }).click() await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) - expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) + expect(openPath.mock.calls[0]![0]).toEqual(openPath.mock.calls[1]![0]) await page.getByRole('button', { name: 'Cancel' }).click() await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { timeout: 5_000, @@ -552,6 +562,9 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', + 'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', + ]) }) }) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 425ff5d1ee..96673df5f8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -68,12 +68,12 @@ describe('web e2e: settings modal and General preferences', () => { const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) await openDocument.waitFor({ timeout: 10_000 }) let openRequests = 0 - await page.route('**/api/settings.openDocument', async (route) => { + await page.route('**/api/settings/openSettingsDocument', async (route) => { const envelope = route.request().postDataJSON() as { rpcId: string - payload: Record + payload: { args: Record } } - expect(envelope.payload).toEqual({}) + expect(envelope.payload).toEqual({ args: {} }) openRequests += 1 await route.fulfill({ status: 200, @@ -88,7 +88,7 @@ describe('web e2e: settings modal and General preferences', () => { await openDocument.click() await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) - await page.unroute('**/api/settings.openDocument') + await page.unroute('**/api/settings/openSettingsDocument') // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) @@ -408,6 +408,36 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('persists the completed-Turn transcript mode across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() + await dialog.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 }) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-chat:\n\s+transcriptView: normal/) + await page.keyboard.press('Escape') + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('button', { name: '设置', exact: true }).click() + const reloaded = page.getByRole('dialog', { name: '设置' }) + await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 }) + + await reloaded.getByRole('button', { name: 'Normal', exact: true }).click() + await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() + await reloaded.getByRole('button', { name: 'Compact', exact: true }).waitFor({ timeout: 10_000 }) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-chat:\n\s+transcriptView: compact/) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('persists the busy-state Enter behavior across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 25914d5fa0..9658d3692c 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -10,7 +10,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url)) @@ -42,7 +42,9 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() - await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 }) + const skillRow = page.locator('[data-tool="skill"]') + await expandOwningTurnProcess(page, skillRow) + await skillRow.waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 1248d12df9..e9b6891b7b 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { assertFixtureInventory, + captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, @@ -21,10 +22,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md') const MODE = webSnapshotMode() const SKILL_NAME = 'user-invoke-demo' @@ -120,10 +122,16 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) // The rendered body arrives as a context-injection row named after the - // skill; expanding it reveals the canonical block, and - // the user's text is NOT folded into it. + // skill. Context plus the final answer contributes no summary count, so + // the Turn uses the fallback title while the row's own disclosure remains usable. + const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME }) + await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 }) + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + await settled + const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) + await process.waitFor({ state: 'visible', timeout: 10_000 }) + await expandOwningTurnProcess(page, injectionFlow) const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) - await injectionRow.waitFor({ timeout: 15_000 }) await injectionRow.click() const injectionBody = page .locator('[data-context-injection-body]') @@ -133,18 +141,21 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro expect(injected).toContain('Reply with the fixture acknowledgement line.') expect(injected).not.toContain(ARGS_TEXT) await injectionRow.click() - - // The injection started a turn; the replay adapter answers it. - await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) - await settled + await process.click() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) it('keeps its snapshot inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md']) }) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index ba28fd09ad..d5d1b0c6c8 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -590,16 +590,16 @@ describe('dsh web keyless CLI smoke', () => { } }, 120_000) - it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => { + it('DSH_TOOLS_MODE=ptc collapses the provider wire tools to run_code with the SDK prompt section', async () => { requireDist() - const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-')) + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) - interface CodeModeProviderRequest { + interface PtcModeProviderRequest { messages?: { role?: string; content?: string }[] tools?: { function?: { name?: string } }[] } - let resolveProviderRequest!: (request: CodeModeProviderRequest) => void - const providerRequest = new Promise((resolve) => { + let resolveProviderRequest!: (request: PtcModeProviderRequest) => void + const providerRequest = new Promise((resolve) => { resolveProviderRequest = resolve }) const provider = createServer((request, response) => { @@ -607,7 +607,7 @@ describe('dsh web keyless CLI smoke', () => { request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as CodeModeProviderRequest) + resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end([ 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', @@ -629,9 +629,9 @@ describe('dsh web keyless CLI smoke', () => { cwd: workspace, env: { ...process.env, - DEEPSEEK_API_KEY: 'keyless-web-code-mode', + DEEPSEEK_API_KEY: 'keyless-web-ptc', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - DSH_TOOLS_MODE: 'code', + DSH_TOOLS_MODE: 'ptc', DSH_HOME: join(workspace, '.dsh'), DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index ebcb6163c7..cd696c5411 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -24,7 +24,7 @@ - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index e257aa031d..1404402b4f 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -130,7 +130,7 @@ describe('web e2e: startup auto-selection', () => { expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true) releaseOpening() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningsBefore) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 41f42aaf31..87981a5713 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -11,7 +11,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -24,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // from user/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md') const MODE = webSnapshotMode() // The question composer replaces the textarea, so fill → Queue row → Steer // starts only after request/context and must finish before the first replay @@ -43,6 +45,7 @@ const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl') const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json') const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md') const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md') +const STEER_ALL_SETTLED_EXPANDED = join(STEER_ALL_DIR, 'settled-expanded.expected.md') const STEER_ONE = 'Interjection: include the word BANANA in your final reply.' const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.' @@ -171,12 +174,20 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // obeying reply, composer takeover gone. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md', + ]) }) }) @@ -392,13 +403,20 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { expect(await page.locator('[data-pending-steering]').count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(STEER_ALL_SETTLED_EXPANDED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(STEER_ALL_DIR, [ - 'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md', + 'replay.override.json', 'mid-steer.expected.md', + 'settled.expected.md', 'settled-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6f8371f99f..7b792528e7 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -11,7 +11,8 @@ import { import type {} from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { - acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden, + acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' @@ -19,6 +20,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url)) +const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath( + new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url), +) const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url)) const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url)) @@ -243,7 +247,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const catalogButton = page.getByRole('button', { name: /subagents/ }) + const catalogButton = page.getByRole('button', { name: '3 subagents', exact: true }) await catalogButton.waitFor({ timeout: 15_000 }) await catalogButton.hover() const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) @@ -393,7 +397,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await page.locator('[data-composer-seat]').evaluate(element => getComputedStyle(element).visibility)).toBe('hidden') releaseCatalog() - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.waitFor({ timeout: 15_000 }) await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) acknowledgeReloadConnectionLoss(tripwire, warningStart) @@ -417,7 +421,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = resolveEnded() }) }) - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.fill(FOLLOWUP) await input.press('Enter') await expect.poll( @@ -434,6 +438,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria')) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(AVAILABLE_CHILD_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) @@ -500,7 +510,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .click() await page.getByRole('button', { name: '3 subagents' }).hover() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() + await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor() const forkResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 781c38f024..6f2ca01325 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -259,7 +259,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co .getByRole('button').first().click() await page.getByRole('button', { name: /1 subagent/ }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.waitFor({ timeout: 15_000 }) expect(await input.isDisabled()).toBe(false) diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts new file mode 100644 index 0000000000..a5d2b5503f --- /dev/null +++ b/apps/web/tests/submission-echo.e2e.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// Local submission echo over the BUILT client graph (keyless fixture Connection RPC +// transport): a text-plus-image send paints its echo bubble synchronously on +// the submit keystroke — before serialization, transport, or the fixture's +// durable admission — with the composer already cleared and editable, and the +// durable user/message replaces the echo without a duplicate. The fixture host +// echoes the prompt requestId as the durable source's rpcId, so the retirement +// path here is the production correlation, not a test hook. +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + + const composer = await waitFor(() => { + const surface = document.querySelector('[data-composer-input]') + if (surface === null) throw new Error('composer surface missing') + return surface + }, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' }) + fireEvent.paste(composer, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + throw new Error('attachment rail missing') + } + }, { timeout: 5_000 }) + fireEvent.paste(composer, { + clipboardData: { items: [], getData: () => '回显这条消息' }, + }) + await waitFor(() => { expect(composer.textContent).toBe('回显这条消息') }) + fireEvent.keyDown(composer, { key: 'Enter' }) + + // Synchronously after the keystroke: the echo bubble is in the flow with + // the draft text and the object-URL preview, while the prompt has not even + // been serialized yet (it starts after a paint yield). The composer is + // already cleared, editable, and free of the rail. + const echo = document.querySelector('[data-submission-echo]') + if (echo === null) throw new Error('submission echo missing on the send keystroke') + expect(echo.textContent).toContain('回显这条消息') + expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') + expect(composer.textContent).toBe('') + expect(composer.getAttribute('contenteditable')).toBe('true') + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + + // The fixture's durable user/message (source.rpcId echoes the prompt + // requestId) replaces the echo: one bubble, no marker left, and the image + // now renders from the durable gallery. + await waitFor(() => { + if (document.querySelector('[data-submission-echo]') !== null) { + throw new Error('submission echo still present after the durable node arrived') + } + }, { timeout: 10_000 }) + expect(screen.getAllByText('回显这条消息')).toHaveLength(1) + await waitFor(() => { + if (document.querySelector('[data-align="end"] img') === null) { + throw new Error('durable user gallery missing') + } + }, { timeout: 10_000 }) +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 66bfd4111d..63cf8c3ff8 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync } from 'node:fs' import { createServer } from 'node:net' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) @@ -31,6 +31,35 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise

{ + const controls = page.locator('[data-turn-process]') + await controls.first().waitFor({ state: 'visible', timeout: 10_000 }) + const count = await controls.count() + for (let index = 0; index < count; index++) { + const control = controls.nth(index) + if (await control.getAttribute('aria-expanded') !== 'true') await control.click() + } +} + +/** + * Expand the Turn-process group containing one possibly hidden descendant. + * @param page - page containing the Chat view. + * @param target - descendant whose owning Turn process should open. + */ +export async function expandOwningTurnProcess(page: Page, target: Locator): Promise { + const turn = await target.evaluate(element => element.closest('[data-chat-turn]')?.dataset.chatTurn) + if (turn === undefined || await target.isVisible()) return + const control = page.locator(`[data-turn-process="${turn}"]`) + await control.waitFor({ state: 'visible', timeout: 10_000 }) + if (await control.getAttribute('aria-expanded') !== 'true') await control.click() +} + /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ export function requireDist(): void { if (!existsSync(DIST_INDEX)) { @@ -83,7 +112,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await dialog.getByRole('button', { name: 'Open', exact: true }).click() // The pick connected the workspace: the blank session's live composer // replaces the locked placeholder and enables. - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -106,7 +135,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或对话"]') .waitFor({ timeout: 15_000 }) } diff --git a/apps/web/tests/todo-row.expected.e2e.ts b/apps/web/tests/todo-row.expected.e2e.ts index 5672dd09a0..21fa70d9bf 100644 --- a/apps/web/tests/todo-row.expected.e2e.ts +++ b/apps/web/tests/todo-row.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless FixtureApiClient transport, opens the fixture session, and pins the +// keyless fixture Connection RPC, opens the fixture session, and pins the // two surfaces the fixture's parallel plan (turn 74, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 07ed9330b2..315d3baa1e 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Trajectory image surfaces over the BUILT client graph (the ptc-fixture +// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the Trajectory surfaces: // selecting the ledger record renders the shared ui-attachment gallery from diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 9a78fb9f9c..c207d9278b 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -24,10 +24,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// Two goldens for the same message: parked mid-turn, then settled. +// Three goldens for the same message: parked mid-turn, aborted, and completed. const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md') +const COMPLETED_EXPECTED = join(SNAPSHOT_DIR, 'completed.expected.md') +const FOCUSED_EXPECTED = join(SNAPSHOT_DIR, 'focused.expected.md') const MODE = webSnapshotMode() // The recording must carry text in the SAME assistant message as the tool @@ -60,7 +62,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { }) /** Boot scaffold + page, materializing the sidecar before the replay row installs. */ - async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise { + async function launch( + buildOverride?: (sidecarHome: string) => ReplayOverrideDoc, + paceMs?: number, + ): Promise { sessionEvents = [] let overridePath: string | undefined if (buildOverride !== undefined) { @@ -75,6 +80,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { replayFixture: FIXTURE, ...(overridePath === undefined ? {} : { replayOverride: overridePath }), compareReplaySession: overridePath === undefined, + ...(paceMs === undefined ? {} : { paceMs }), }, ) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) @@ -122,13 +128,13 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions')) // The barrier is armed before the park and awaited only after the stop // click, so its budget must cover the whole parked phase: marker poll, - // three UI polls, and two captures with their stability windows. The + // live-state polls, and two captures with their stability windows. The // replay default (30s) leaves no headroom on a slow runner. const { settled } = await sendPrompt(120_000) // The marker IS the synchronization: the second call is provably parked, // so the first step's message and tool result are already durable. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) - await expect.poll(() => page.getByText(NARRATION, { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await page.locator('[data-turn-process]').count()).toBe(0) await expect.poll( () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), { timeout: 10_000 }, @@ -148,6 +154,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await page.getByRole('button', { name: 'Stop generating' }).click() await settled expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted']) + await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) await copyButtons.last().focus() @@ -182,9 +189,89 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(tripwire.warnings).toEqual([]) }, 120_000) + it.skipIf(MODE === 'record')('folds the Turn process after the completed reply becomes the answer', async () => { + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-completed')) + const { settled } = await sendPrompt() + await settled + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const process = page.locator('[data-turn-process]') + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await process.evaluate(element => getComputedStyle(element).borderBottomWidth)).toBe('1px') + const processBottom = await process.evaluate(element => + element.closest('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom) + const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => + element.closest('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top) + expect(answerTop).toBe((processBottom ?? 0) + 8) + await process.focus() + const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => { + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting')) + const { settled } = await sendPrompt() + await settled + const process = page.locator('[data-turn-process]') + const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' }) + await process.waitFor({ timeout: 10_000 }) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() + await page.keyboard.press('Escape') + + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0) + await tool.waitFor({ state: 'visible', timeout: 10_000 }) + await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-chat:\n\s+transcriptView: normal/) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const restored = page.getByRole('dialog', { name: 'Settings' }) + await restored.getByRole('button', { name: 'Normal', exact: true }).click() + await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() + await page.keyboard.press('Escape') + await process.waitFor({ timeout: 10_000 }) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps a focused process member open when the completed reply arrives', async () => { + await launch(undefined, 200) + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused')) + const { settled } = await sendPrompt() + const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' }) + await tool.waitFor({ timeout: 30_000 }) + await tool.focus() + expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true) + await settled + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const process = page.locator('[data-turn-process]') + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1) + expect(await process.getAttribute('aria-expanded')).toBe('true') + expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true) + const focused = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(FOCUSED_EXPECTED, focused, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, [ - 'running.expected.md', 'session.jsonl', 'settled.expected.md', 'usage-expanded.expected.md', - ]) + await assertFixtureInventory( + SNAPSHOT_DIR, + [ + 'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.jsonl', + 'settled.expected.md', 'usage-expanded.expected.md', + ], + ) }) }) diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index bba79e3ad4..5f9e118767 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -16,7 +16,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.jsonl', import.meta.url)) @@ -263,7 +263,9 @@ describe('web e2e: shipped default web search', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria')) await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 }) .toBeGreaterThanOrEqual(1) - await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 }) + const searchTool = page.locator('[data-tool="web_search"]') + await expandOwningTurnProcess(page, searchTool) + await searchTool.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) @@ -271,6 +273,7 @@ describe('web e2e: shipped default web search', () => { it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll')) const row = page.locator('[data-tool="web_search"] [data-expandable]').first() + await expandOwningTurnProcess(page, row) await row.click() await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') @@ -299,6 +302,7 @@ describe('web e2e: shipped default web search', () => { it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room')) + await expandOwningTurnProcess(page, page.locator('[data-tool="web_search"]')) // `overflow-y: auto` clips inline-start overflow with no way to scroll it // back, and markers are right-aligned to the content edge, so a marker wider // than `padding-left` silently loses its leading digits. `searchMaxResults` diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index ede39f8e0f..da846b1c6b 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -15,7 +15,7 @@ import { type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot, + connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, } from './support.ts' const MODE = webSnapshotMode() @@ -161,6 +161,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() await settled + await expandTurnProcesses(page) await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1) @@ -186,6 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history')) await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expandTurnProcesses(page) const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) await workflow.waitFor({ timeout: 15_000 }) expect(await workflow.getAttribute('aria-expanded')).toBe('false') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0388704e61..2357de9d14 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -59,7 +59,7 @@ "tests/sidebar-scrollbar.e2e.ts", "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", - "tests/code-mode-round.e2e.ts", + "tests/ptc-round.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", @@ -82,6 +82,7 @@ "tests/shipped-composition.e2e.ts", "tests/schedule-after.e2e.ts", "tests/feedback-command.e2e.ts", + "tests/feedback-release.e2e.ts", "tests/agent-team-panel.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 07858f39c4..c5feee2c91 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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 docs/api-gateway.md -api-gateway.md: 86c43ccc5719107ded1e4004d6fabbc32b760520 -api-gateway.zh.md: 6fa170b84129eb96a70d0831158a9f9c43db256c +api-gateway.md: 43b00bcff3da0adb1d53ac25534b65366bc42611 +api-gateway.zh.md: bb2711220fd75ef0119e1188c22a0af5ad4169a8 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 86c43ccc57..43b00bcff3 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -118,13 +118,13 @@ Strict analysis requires a Remote to be a public, non-static instance method wit ## Runtime invocation -Remote and API Proxy share the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; feature-owned exact Fetch routes handle non-JSON responses, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. -The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. @@ -159,6 +159,6 @@ The running Client watcher consumes these generated files when it rebundles. If Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. -The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. The API Proxy at `packages/host/apiproxy` handles endpoints without Remote descriptors. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 6fa170b841..bb2711220f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -118,13 +118,13 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 ## 运行时调用 -Remote 与 API Proxy 共用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;功能自有的精确 Fetch 路由处理非 JSON 响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 -lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的陈旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 @@ -159,6 +159,6 @@ pnpm run build:lib Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 -API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。位于 `packages/host/apiproxy` 的 API Proxy 处理没有 Remote 描述符的 endpoint。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 5d1d98a404..e004cf0452 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: 1e7e6e39d307a9e72b5d57420bde99f51063f64d -capability-seams.zh.md: e33a1e6da7f71838c48b961f93389ba1a089f57f +capability-seams.md: 5c3ba40df35f40e211de2bf90037f998eeb5a15d +capability-seams.zh.md: 47402e1b6d9514a77e8fff6c4f0d0a8bbb34586d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1e7e6e39d3..5c3ba40df3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -11,7 +11,6 @@ flowchart LR svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] pkg_api_session_controller["api-session-controller"] - pkg_host_apiproxy["host-apiproxy"] pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] @@ -38,6 +37,8 @@ flowchart LR pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] + svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] + svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] @@ -112,6 +113,7 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] + pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -166,7 +168,6 @@ flowchart LR pkg_fs_observation_policy["fs-observation-policy"] pkg_compaction["compaction"] svc_compaction["ctx.compaction
Compaction seam"] - pkg_subagent["subagent"] svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_subagent_fork_in_process["subagent-fork-in-process"] @@ -177,6 +178,8 @@ flowchart LR svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_inspector["inspector"] + svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] @@ -211,7 +214,6 @@ flowchart LR pkg_lsp["lsp"] svc_lsp["ctx.lsp
Language-server navigation seam"] pkg_tool_lsp["tool-lsp"] - svc_apiProxy["ctx.apiProxy
Host API dispatch"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] @@ -221,6 +223,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_session_controller --> svc_sessionFileReferences + pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController @@ -251,11 +255,11 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals - pkg_host_apiproxy --> svc_apiProxy pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker pkg_host_webserver --> svc_webServer + pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants pkg_jobs --> svc_jobs pkg_jobs_local --> svc_jobs @@ -329,20 +333,18 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless - svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent_in_process_driver - svc_apiProxy --> pkg_client_connection svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_attachments --> pkg_api_session_controller - svc_attachments --> pkg_host_apiproxy svc_attachments --> pkg_llm_deepseek svc_attachments --> pkg_llm_pi_ai svc_attachments --> pkg_tool_fs @@ -351,7 +353,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compaction --> pkg_compaction_basic svc_cordisInspect --> pkg_tool_cordis - svc_credentials --> pkg_host_apiproxy + svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -359,6 +361,7 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b + svc_fileReferences --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -376,7 +379,6 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash - svc_sessionController --> pkg_host_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -384,8 +386,11 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash - svc_sessionProjectionCache --> pkg_host_apiproxy - svc_sessionProjections --> pkg_host_apiproxy + svc_sessionProjectionCache --> pkg_api_session_controller + svc_sessionProjectionCache --> pkg_session_query + svc_sessionProjectionCache --> pkg_session_reference + svc_sessionProjectionCache --> pkg_subagent + svc_sessionProjections --> pkg_api_session_controller svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference @@ -398,7 +403,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_in_process_driver - svc_settings --> pkg_host_apiproxy + svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai svc_shell --> pkg_hooks_claude_code @@ -458,13 +463,15 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`host-apiproxy`](../packages/host/apiproxy), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | +| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | +| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | @@ -473,9 +480,9 @@ flowchart LR | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | @@ -483,20 +490,20 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | -| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents. | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | -| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | -| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | +| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | @@ -508,11 +515,12 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | -| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | +| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for PTC mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | +| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | @@ -522,7 +530,6 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/webhook/webhook) | - | [`webhook-github`](../packages/webhook/webhook-github) | - | Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state. | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result. | -| `ctx.apiProxy` | `core` | [`host-apiproxy`](../packages/host/apiproxy) | - | [`client-connection`](../packages/client/connection) | - | The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb. | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace. | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index e33a1e6da7..47402e1b6d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -13,7 +13,6 @@ flowchart LR svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] pkg_api_session_controller["api-session-controller"] - pkg_host_apiproxy["host-apiproxy"] pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] @@ -40,6 +39,8 @@ flowchart LR pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
Host Session Remote controller"] + svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] + svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] @@ -114,6 +115,7 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] + pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -168,7 +170,6 @@ flowchart LR pkg_fs_observation_policy["fs-observation-policy"] pkg_compaction["compaction"] svc_compaction["ctx.compaction
Compaction seam"] - pkg_subagent["subagent"] svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_subagent_fork_in_process["subagent-fork-in-process"] @@ -179,6 +180,8 @@ flowchart LR svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_inspector["inspector"] + svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] @@ -213,7 +216,6 @@ flowchart LR pkg_lsp["lsp"] svc_lsp["ctx.lsp
Language-server navigation seam"] pkg_tool_lsp["tool-lsp"] - svc_apiProxy["ctx.apiProxy
Host API dispatch"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] @@ -223,6 +225,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_session_controller --> svc_sessionFileReferences + pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController @@ -253,11 +257,11 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals - pkg_host_apiproxy --> svc_apiProxy pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker pkg_host_webserver --> svc_webServer + pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants pkg_jobs --> svc_jobs pkg_jobs_local --> svc_jobs @@ -331,20 +335,18 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless - svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent_in_process_driver - svc_apiProxy --> pkg_client_connection svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_attachments --> pkg_api_session_controller - svc_attachments --> pkg_host_apiproxy svc_attachments --> pkg_llm_deepseek svc_attachments --> pkg_llm_pi_ai svc_attachments --> pkg_tool_fs @@ -353,7 +355,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compaction --> pkg_compaction_basic svc_cordisInspect --> pkg_tool_cordis - svc_credentials --> pkg_host_apiproxy + svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -361,6 +363,7 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b + svc_fileReferences --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -378,7 +381,6 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash - svc_sessionController --> pkg_host_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -386,8 +388,11 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash - svc_sessionProjectionCache --> pkg_host_apiproxy - svc_sessionProjections --> pkg_host_apiproxy + svc_sessionProjectionCache --> pkg_api_session_controller + svc_sessionProjectionCache --> pkg_session_query + svc_sessionProjectionCache --> pkg_session_reference + svc_sessionProjectionCache --> pkg_subagent + svc_sessionProjections --> pkg_api_session_controller svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference @@ -400,7 +405,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_in_process_driver - svc_settings --> pkg_host_apiproxy + svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai svc_shell --> pkg_hooks_claude_code @@ -460,13 +465,15 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`host-apiproxy`](../packages/host/apiproxy), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | -| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | +| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | +| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | @@ -475,9 +482,9 @@ flowchart LR | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;settings controller 提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | @@ -485,20 +492,20 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | -| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | 该接口通过其一元 Remote 契约返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | 该接口返回 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问与排序,但不读取文件内容。 | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | -| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,api-proxy 提供基线并推送发生变化的值。 | -| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | +| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | @@ -510,11 +517,12 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 统一保存部署默认模式和工作区根目录;只有沙箱执行器和提供方读取该服务(工具层使用它同时导出的纯 `sandbox/mode` 折叠区)。两类强制执行组件都读取该服务,因此 bash 与 fs 不会限制到不同的根目录。 | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | 一次性权限决策通过 `approval/request` waterfall(瀑布式事件)分派;回答方是监听器(即 ACP 为自身 agent 提供的桥接),没有回答方时以 `unavailable` 关闭失败。 | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | 面向用户的预设表(`workspace-write`/`danger-full-access`),将沙箱模式与审批策略选项组合在一起;一次切换会写入一个 `permission/preset` 事件,并贯通到两个选项事件。 | -| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | 使用 Host 提供的异步绑定运行一段由模型编写的程序;各后端采用不同的基础环境和语言(工具注册表在 Code Mode 下消费该服务)。 | +| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | 使用 Host 提供的异步绑定运行一段由模型编写的程序;各后端采用不同的基础环境和语言(工具注册表在 PTC mode 下消费该服务)。 | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | +| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | @@ -524,7 +532,6 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/webhook/webhook) | - | [`webhook-github`](../packages/webhook/webhook-github) | - | 提供方适配器分派已认证交付;可信插件注册独立的进程本地规则,runtime 把非 null 结果转换为普通的 Workspace-backed Session,不保留交付或完成状态。 | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | 提供方注册与选择,加上恰好四种操作的标准化查询执行;该 seam 不提供协议逃生口,后端必须转换为标准化请求和结果。 | -| `ctx.apiProxy` | `core` | [`host-apiproxy`](../packages/host/apiproxy) | - | [`client-connection`](../packages/client/connection) | - | 与传输无关的 Host 网关接口:它分派浏览器 API 调用,每条打开的 Host 流自行订阅转发事件,而不是由广播方法向其推送。 | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 拥有内存定义注册表、Host 半的 vm 沙箱和 request-run 往返流程;浏览器页面通过其 Remote 命名空间在线访问同一服务。 | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 注册 Host inspect 提供方、镜像 Client 提供方 manifest,并通过动态 Cordis 传输路由 Client 查询。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d7081d60fc..ba3f56d510 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 16508819d0cc46ff93707337d97c6f88541152fb -config-catalog.zh.md: 049f266ed8f9014bfd377ae515efdb920b952f83 +config-catalog.md: b2d8379dd799ab0e836a2ab0572d6af08ababc5d +config-catalog.zh.md: d768b40c54d27aeeb1294d4fee7417f54c11afb0 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 16508819d0..b2d8379dd7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -261,7 +261,7 @@ Requires: `tools` export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, - * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * `ptc` sends only `run_code` plus a generated SDK, `both` sends both. * Required rather than defaulted: the deployment default is what a preset * without this row already gets, so an omitted value would mean the row was * composed for nothing. @@ -274,6 +274,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +Requires: `typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +Source: [`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -285,10 +301,26 @@ Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } ``` -Source: [`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) + + + +## `@deepseek-ai/dsh-api-settings-controller` + +```ts config-catalog +/** Native document-opening policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} +``` + +Source: [`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) @@ -397,7 +429,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts) @@ -596,6 +628,74 @@ export interface Config { Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) + + +## `@deepseek-ai/dsh-experimental-inspector` + +Requires: `webServer` + +```ts config-catalog +/** Host plugin configuration. Fetch capture is enabled by default. */ +export interface Config extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +/** User-facing Host options; every memory and lifecycle bound is configurable. */ +export interface InspectorOptions { + /** Loopback address used by the Worker HTTP and WebSocket endpoint. */ + readonly host?: '127.0.0.1' + /** First port to bind; occupied ports advance until one is available. */ + readonly port?: number + /** Additional exact browser origins admitted to the Client ingest socket. */ + readonly clientOrigins?: readonly string[] + /** Whether to observe calls made through the current global fetch function. */ + readonly captureFetch?: boolean + /** Maximum request-body prefix retained for one fetch. */ + readonly maxRequestBodyBytes?: number + /** Maximum response-body prefix retained for one fetch. */ + readonly maxResponseBodyBytes?: number + /** Maximum raw bytes encoded into one body observation. */ + readonly maxBodyChunkBytes?: number + /** Maximum total request and response body bytes retained by the Worker. */ + readonly maxJournalBytes?: number + /** Maximum active and completed fetch requests retained by the Worker. */ + readonly maxRetainedRequests?: number + /** Maximum encoded bytes accepted in one source transport frame. */ + readonly maxSourceFrameBytes?: number + /** Maximum observation records accepted in one source batch. */ + readonly maxSourceRecordsPerFrame?: number + /** Maximum records waiting in one producer queue. */ + readonly maxQueuedRecords?: number + /** Maximum encoded bytes waiting in one producer queue. */ + readonly maxQueuedBytes?: number + /** Maximum time allowed for the Worker to become ready. */ + readonly startupTimeoutMs?: number + /** Grace period before a stopping Worker is terminated. */ + readonly stopTimeoutMs?: number + /** Initial upper bound for randomized Client reconnect delay. */ + readonly clientReconnectBaseMs?: number + /** Maximum upper bound for randomized Client reconnect delay. */ + readonly clientReconnectMaxMs?: number + /** Deadline for one Worker-to-Client Runtime or Sources request. */ + readonly clientRuntimeTimeoutMs?: number + /** Deadline for one non-CDP semantic query. */ + readonly queryTimeoutMs?: number + /** Maximum live object handles retained per Client Runtime session. */ + readonly maxClientRuntimeObjects?: number + /** Maximum descriptors returned by one Client property request. */ + readonly maxClientRuntimeProperties?: number + /** Maximum encoded bytes read for one Client script or source map. */ + readonly maxClientSourceBytes?: number + /** Maximum Context and Fiber nodes retained in one realm snapshot. */ + readonly maxCordisNodes?: number + /** Disconnected Cordis snapshots retained after their live realm closes. */ + readonly maxDisconnectedCordisTrees?: number +} +``` + +Source: [`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts) + ## `@deepseek-ai/dsh-experimental-tool-agent-team` @@ -770,34 +870,6 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) - - -## `@deepseek-ai/dsh-host-apiproxy` - -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController` - -```ts config-catalog -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} -``` - -Source: [`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) - ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -1752,6 +1824,25 @@ export interface Config { Source: [`packages/session/session-log-deepseek/src/index.ts:22`](../packages/session/session-log-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-session-log-export` + +Requires: `commands` · `connection` + +```ts config-catalog +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +``` + +Source: [`packages/session-query/session-log-export/src/index.ts:41`](../packages/session-query/session-log-export/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -1840,7 +1931,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:45`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts) @@ -2863,12 +2954,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** @@ -2918,7 +3006,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) @@ -3038,13 +3126,13 @@ Requires: `systemPrompt` /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` + * Model presentation. `native` (default) sends every visible schema; `ptc` * sends only `run_code` plus a generated SDK prompt and collapses the * executor to the same surface (a model-direct call may only name * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both` - * sends both forms. Code modes require a `ctx.codeRuntime` whose `language` + * sends both forms. PTC mode requires a `ctx.codeRuntime` whose `language` * has a registered SDK renderer (TypeScript or Python) and fail prompt - * assembly when it is absent or has no renderer. Under `code`, native names + * assembly when it is absent or has no renderer. Under `ptc`, native names * in `toolOrder` are invalid. */ mode?: ToolPresentationMode @@ -3059,7 +3147,7 @@ export interface Config { } /** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'code' | 'both' +export type ToolPresentationMode = 'native' | 'ptc' | 'both' ``` Source: [`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts) @@ -3317,9 +3405,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) @@ -3379,7 +3465,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) -- `@deepseek-ai/dsh-session-log-export` — requires `commands` ([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 049f266ed8..d768b40c54 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -263,7 +263,7 @@ export interface GoalConfig { export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, - * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * `ptc` sends only `run_code` plus a generated SDK, `both` sends both. * Required rather than defaulted: the deployment default is what a preset * without this row already gets, so an omitted value would mean the row was * composed for nothing. @@ -276,6 +276,22 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +需要:`typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +来源:[`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -287,10 +303,26 @@ export interface Config { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } ``` -来源:[`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) + + + +## `@deepseek-ai/dsh-api-settings-controller` + +```ts config-catalog +/** Native document-opening policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} +``` + +来源:[`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) @@ -598,6 +630,74 @@ export interface Config { 来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts) + + +## `@deepseek-ai/dsh-experimental-inspector` + +需要:`webServer` + +```ts config-catalog +/** Host plugin configuration. Fetch capture is enabled by default. */ +export interface Config extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +/** User-facing Host options; every memory and lifecycle bound is configurable. */ +export interface InspectorOptions { + /** Loopback address used by the Worker HTTP and WebSocket endpoint. */ + readonly host?: '127.0.0.1' + /** First port to bind; occupied ports advance until one is available. */ + readonly port?: number + /** Additional exact browser origins admitted to the Client ingest socket. */ + readonly clientOrigins?: readonly string[] + /** Whether to observe calls made through the current global fetch function. */ + readonly captureFetch?: boolean + /** Maximum request-body prefix retained for one fetch. */ + readonly maxRequestBodyBytes?: number + /** Maximum response-body prefix retained for one fetch. */ + readonly maxResponseBodyBytes?: number + /** Maximum raw bytes encoded into one body observation. */ + readonly maxBodyChunkBytes?: number + /** Maximum total request and response body bytes retained by the Worker. */ + readonly maxJournalBytes?: number + /** Maximum active and completed fetch requests retained by the Worker. */ + readonly maxRetainedRequests?: number + /** Maximum encoded bytes accepted in one source transport frame. */ + readonly maxSourceFrameBytes?: number + /** Maximum observation records accepted in one source batch. */ + readonly maxSourceRecordsPerFrame?: number + /** Maximum records waiting in one producer queue. */ + readonly maxQueuedRecords?: number + /** Maximum encoded bytes waiting in one producer queue. */ + readonly maxQueuedBytes?: number + /** Maximum time allowed for the Worker to become ready. */ + readonly startupTimeoutMs?: number + /** Grace period before a stopping Worker is terminated. */ + readonly stopTimeoutMs?: number + /** Initial upper bound for randomized Client reconnect delay. */ + readonly clientReconnectBaseMs?: number + /** Maximum upper bound for randomized Client reconnect delay. */ + readonly clientReconnectMaxMs?: number + /** Deadline for one Worker-to-Client Runtime or Sources request. */ + readonly clientRuntimeTimeoutMs?: number + /** Deadline for one non-CDP semantic query. */ + readonly queryTimeoutMs?: number + /** Maximum live object handles retained per Client Runtime session. */ + readonly maxClientRuntimeObjects?: number + /** Maximum descriptors returned by one Client property request. */ + readonly maxClientRuntimeProperties?: number + /** Maximum encoded bytes read for one Client script or source map. */ + readonly maxClientSourceBytes?: number + /** Maximum Context and Fiber nodes retained in one realm snapshot. */ + readonly maxCordisNodes?: number + /** Disconnected Cordis snapshots retained after their live realm closes. */ + readonly maxDisconnectedCordisTrees?: number +} +``` + +来源:[`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts) + ## `@deepseek-ai/dsh-experimental-tool-agent-team` @@ -772,34 +872,6 @@ export interface Config { 来源:[`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) - - -## `@deepseek-ai/dsh-host-apiproxy` - -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController` - -```ts config-catalog -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} -``` - -来源:[`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) - ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -1754,6 +1826,25 @@ export interface Config { 来源:[`packages/session/session-log-deepseek/src/index.ts:22`](../packages/session/session-log-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-session-log-export` + +需要:`commands` · `connection` + +```ts config-catalog +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +``` + +来源:[`packages/session-query/session-log-export/src/index.ts:41`](../packages/session-query/session-log-export/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -2865,12 +2956,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** @@ -3040,13 +3128,13 @@ export interface Config { /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` + * Model presentation. `native` (default) sends every visible schema; `ptc` * sends only `run_code` plus a generated SDK prompt and collapses the * executor to the same surface (a model-direct call may only name * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both` - * sends both forms. Code modes require a `ctx.codeRuntime` whose `language` + * sends both forms. PTC mode requires a `ctx.codeRuntime` whose `language` * has a registered SDK renderer (TypeScript or Python) and fail prompt - * assembly when it is absent or has no renderer. Under `code`, native names + * assembly when it is absent or has no renderer. Under `ptc`, native names * in `toolOrder` are invalid. */ mode?: ToolPresentationMode @@ -3061,7 +3149,7 @@ export interface Config { } /** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'code' | 'both' +export type ToolPresentationMode = 'native' | 'ptc' | 'both' ``` 来源:[`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts) @@ -3319,9 +3407,7 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) @@ -3381,7 +3467,6 @@ export interface Config { - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) -- `@deepseek-ai/dsh-session-log-export` — 需要 `commands`([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 3aaabe5ef5..9d02664a90 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-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 docs/cookbook/adding-a-tool.md -adding-a-tool.md: 9ec025426a2304f4bda56ddb8e6f5c42e3e8f4a8 -adding-a-tool.zh.md: 2c73b40ff53b5b58507b716ff32dd1fea26148f4 +adding-a-tool.md: f6b2703f3db69a6abc04dbd0fd9893555eed0602 +adding-a-tool.zh.md: d4831e2be061c2565e88004062cbb1558491bb9d diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 9ec025426a..f6b2703f3d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -50,7 +50,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S ## Long-running work -Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but Code Mode must never parse that prose to recover the id. +Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id. The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. @@ -58,9 +58,9 @@ The producer supplies synchronous `cancel`, non-rejecting `done` that settles af Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap dispatch with a deadline, retry, or metrics collection, `tools/post-execute` to replace presentation content or the returned value, block the result, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also run inside the tool's executor implementation; the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points) defines each extension point's inputs, order, return values, and failure behavior. -## Code Mode reaches your tool for free +## PTC mode reaches your tool for free -In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The generated `ToolArgsMap` and `ToolOutputMap` derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real `ToolCallError`; programs can inspect only its `name`, `toolName`, and human-readable `message`, not internal error codes or a failure union. +In [PTC mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The generated `ToolArgsMap` and `ToolOutputMap` derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real `ToolCallError`; programs can inspect only its `name`, `toolName`, and human-readable `message`, not internal error codes or a failure union. Design `output.schema` as a useful programmatic API: return handles and fields directly, allow scalar/array/null roots when they are the honest value, and keep human explanation in `output.render`. Intermediate values are execution-local, are not persisted or prompt-truncated, and have no byte cap, so the producer's truthful acquisition bounds and process memory still matter. Only the outer `run_code` logs/result cross the configurable output cap and model-facing spill pipeline. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 2c73b40ff5..d4831e2be0 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -50,7 +50,7 @@ export function apply(ctx: Context) { ## 长时间运行的工作 -通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 Code Mode 绝不能通过解析该文本取得 id。 +通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。 producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 @@ -60,9 +60,9 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.zh.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为分发添加截止时间、重试或指标收集;使用 `tools/post-execute` 替换展示内容或返回值、阻止结果,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略会屏蔽或替换该值。沙箱实现也可以在工具的执行器实现中运行;[`dsh-tools` README](../../packages/core/tools/README.zh.md#extension-points) 定义每个扩展点的输入、顺序、返回值和失败行为。 -## Code Mode 自动触达你的工具 +## PTC mode 自动触达你的工具 -在 [Code Mode](../../packages/core/tools/README.zh.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。生成的 `ToolArgsMap` 和 `ToolOutputMap` 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的 Native 内容。失败调用会以真正的 `ToolCallError` reject;程序只能检查其 `name`、`toolName` 和可供人阅读的 `message`,无法取得内部错误代码或失败联合。 +在 [PTC mode](../../packages/core/tools/README.zh.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。生成的 `ToolArgsMap` 和 `ToolOutputMap` 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的 Native 内容。失败调用会以真正的 `ToolCallError` reject;程序只能检查其 `name`、`toolName` 和可供人阅读的 `message`,无法取得内部错误代码或失败联合。 请把 `output.schema` 设计为实用的程序化 API:直接返回句柄与字段;当标量、数组或 null 确实就是结果时,允许采用相应的根类型;将面向人类的解释放入 `output.render`。中间值只存在于执行期间,不会被持久化或按提示词上限截断,也不设字节上限,因此生产方如实声明的采集边界和进程内存仍然重要。只有外层 `run_code` 日志/结果会受到可配置输出上限和面向模型的 spill 流水线约束。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 24c280ae11..b24e6b88b2 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.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 docs/cookbook/extension-cookbook.md -extension-cookbook.md: 7b8c55837038aa421850449ebf553eb1ef47218f -extension-cookbook.zh.md: 899fcca4ad3c8977044da5a17e40283905abc752 +extension-cookbook.md: bb3f327691d1ff6199407c241f4fa77cd4b23b87 +extension-cookbook.zh.md: db8f0850bdada35cb6d33eed874c44f20d7be9d3 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 7b8c558370..bb3f327691 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -96,7 +96,7 @@ Shipped applications contribute profile layers through `packages/bundle/*/cordis Every product feature maps to a listener on a documented extension point — the microkernel claim made checkable ([microkernel Agent Note](../../.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)). No row modifies the loop. -`system-prompt/assemble` is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active Code Mode and structured-output protocol contributions. Prefer `ctx.tools.restrict()` for tool filtering that must stay aligned across presentation, lookup, and execution. +`system-prompt/assemble` is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active PTC mode and structured-output protocol contributions. Prefer `ctx.tools.restrict()` for tool filtering that must stay aligned across presentation, lookup, and execution. | Product feature | Plugin mechanism | |---|---| diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 899fcca4ad..db8f0850bd 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -100,7 +100,7 @@ export function apply(ctx: Context) { 每个产品功能都映射到一个文档化扩展点上的监听器——微内核声明由此可验证([微内核 Agent Note](../../.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md))。没有任何一行修改循环本身。 -`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 Code Mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。 +`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 PTC mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。 | 产品功能 | 插件机制 | |---|---| diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fad92b97e3..c45e00ced7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: 12797904fab31d613db92af0da0656146eeea51a -development.zh.md: c759f4236efa356c5cc82dcec758654362fcc284 +development.md: 7fc6ae14266253b9e50a1a5f3e9ee6f8e6fbcde7 +development.zh.md: 79e25b489d4c7c3f425460d5bffd0c0265f5b02b diff --git a/docs/development.md b/docs/development.md index 12797904fa..7fc6ae1426 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate; only the split packages above carry both leaf configs, and the shared leaves are registered in both aggregates because each side must type-check the same source. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Five packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, and `client/connection`. `api/remotes`' Host entry must participate in the Host Typert graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order. +Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. The root build follows the generated dependency order: @@ -140,10 +140,10 @@ The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment o pnpm dsh --profile headless "summarize this workspace" ``` -The Code Mode demo runs the same headless profile with code presentation enabled: +The PTC mode demo runs the same headless profile with code presentation enabled: ```sh -pnpm run demo:code-mode -- "summarize this workspace" +pnpm run demo:ptc -- "summarize this workspace" ``` ### TODO markers diff --git a/docs/development.zh.md b/docs/development.zh.md index c759f4236e..79e25b489d 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -63,7 +63,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新包只登记进一个 aggregate;只有上述拆分包同时携带两个 leaf 配置,共享 leaf 因两侧需要对同一份源码做类型检查而登记进两个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。 -拆分 Host/Client tsconfig 的包有五个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller` 与 `client/connection`。`api/remotes` 的 Host 入口必须进入 Host Typert 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此每个拆分包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 说明 Host/Client 拆分与构建顺序。 +拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 根构建按生成依赖排序: @@ -144,10 +144,10 @@ pnpm run build pnpm dsh --profile headless "summarize this workspace" ``` -Code Mode 演示启用代码式工具展示,并运行同一个 headless profile: +PTC mode 演示启用代码式工具展示,并运行同一个 headless profile: ```sh -pnpm run demo:code-mode -- "summarize this workspace" +pnpm run demo:ptc -- "summarize this workspace" ``` ### TODO 标记 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index dc55a4ee59..7883982b1b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 91bfb814a4370fdafb6d62bc79259495b0069dda -event-producer-consumer.zh.md: e7dcd089f28888bdb235ee0a6df97dc4189f0723 +event-producer-consumer.md: 8c3a8a3f0e19f50e5a3164e2f5f4ac0a4e1046eb +event-producer-consumer.zh.md: 65b8fba3dd5b9654ffbd037dded15abe85c5885c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 91bfb814a4..8c3a8a3f0e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | @@ -59,13 +59,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -78,8 +78,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | +| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e7dcd089f2..65b8fba3dd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -45,7 +45,7 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | @@ -61,13 +61,13 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -80,8 +80,8 @@ | 事件字符串 | 派发方 | 监听方 | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | +| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | 维护模式:生成内容。Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8842ee368d..e07ac51810 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: a8549be65dfd33b1fb18632215b7b1d5ab28e36a -module-graph.zh.md: cfea33efc07579aebfc25b91675b8d32bedeeb0e +module-graph.md: 1f1f35789f38e60cb88aee6a6de0c967db53c117 +module-graph.zh.md: 5835824321026e0fd4733a0967289550d704ad02 diff --git a/docs/module-graph.md b/docs/module-graph.md index a8549be65d..1f1f35789f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -210,6 +210,7 @@ flowchart TD pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_experimental_inspector["experimental-inspector"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -229,7 +230,6 @@ flowchart TD pkg_tool_call_timeout_policy["tool-call-timeout-policy"] end subgraph group_host["packages/host"] - pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] @@ -427,6 +427,7 @@ flowchart TD pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout + pkg_llm --> pkg_typert_protocol pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_attachment_local --> pkg_invariants @@ -438,6 +439,9 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment + pkg_experimental_inspector --> pkg_client_modules + pkg_experimental_inspector --> pkg_host_webserver + pkg_experimental_inspector --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -536,16 +540,11 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants - pkg_api_settings_controller --> pkg_session - pkg_api_settings_controller --> pkg_settings - pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants - pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants @@ -1009,9 +1008,27 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval + pkg_api_settings_controller --> pkg_agent_presets + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_native_command + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_brand + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_credentials + pkg_client_connection --> pkg_host_directory_picker + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings + pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1025,8 +1042,6 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1088,17 +1103,11 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_directory_picker - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings - pkg_client_connection --> pkg_tool_todo + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1113,7 +1122,10 @@ flowchart TD pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title pkg_session_reference --> pkg_typert_protocol pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_instructions @@ -1137,10 +1149,13 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_invariants + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_invariants @@ -1200,11 +1215,39 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_api_session_controller --> pkg_agent + pkg_api_session_controller --> pkg_agent_default_model + pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_api_gateway + pkg_api_session_controller --> pkg_attachment + pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_invariants + pkg_api_session_controller --> pkg_jobs + pkg_api_session_controller --> pkg_llm + pkg_api_session_controller --> pkg_native_command + pkg_api_session_controller --> pkg_scope + pkg_api_session_controller --> pkg_session + pkg_api_session_controller --> pkg_session_persistence + pkg_api_session_controller --> pkg_session_projection + pkg_api_session_controller --> pkg_session_projection_cache + pkg_api_session_controller --> pkg_session_query + pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_skill + pkg_api_session_controller --> pkg_subagent + pkg_api_session_controller --> pkg_typert_protocol + pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_workspace_path + pkg_api_session_controller --> pkg_workspace + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1213,9 +1256,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1243,36 +1283,30 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_session_controller --> pkg_agent - pkg_api_session_controller --> pkg_agent_default_model - pkg_api_session_controller --> pkg_agent_presets - pkg_api_session_controller --> pkg_api_gateway - pkg_api_session_controller --> pkg_attachment - pkg_api_session_controller --> pkg_brand - pkg_api_session_controller --> pkg_client_connection - pkg_api_session_controller --> pkg_invariants - pkg_api_session_controller --> pkg_jobs - pkg_api_session_controller --> pkg_llm - pkg_api_session_controller --> pkg_scope - pkg_api_session_controller --> pkg_session - pkg_api_session_controller --> pkg_session_persistence - pkg_api_session_controller --> pkg_session_projection - pkg_api_session_controller --> pkg_session_projection_cache - pkg_api_session_controller --> pkg_session_query - pkg_api_session_controller --> pkg_session_title - pkg_api_session_controller --> pkg_subagent - pkg_api_session_controller --> pkg_typert_protocol - pkg_api_session_controller --> pkg_typert_registry - pkg_api_session_controller --> pkg_util_workspace_path - pkg_api_session_controller --> pkg_workspace - pkg_api_workspace_controller --> pkg_api_gateway - pkg_api_workspace_controller --> pkg_client_connection - pkg_api_workspace_controller --> pkg_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants - pkg_api_workspace_controller --> pkg_session - pkg_api_workspace_controller --> pkg_storage_domain - pkg_api_workspace_controller --> pkg_typert_protocol - pkg_api_workspace_controller --> pkg_workspace + pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller + pkg_api_remotes --> pkg_api_workspace_controller + pkg_api_remotes --> pkg_commands + pkg_api_remotes --> pkg_cordis_host_runner + pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_llm + pkg_api_remotes --> pkg_message_feedback + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_reference + pkg_api_remotes --> pkg_settings + pkg_api_remotes --> pkg_subagent + pkg_api_remotes --> pkg_user_approval + pkg_api_remotes --> pkg_user_questions + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1299,30 +1333,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_api_remotes --> pkg_agent_presets - pkg_api_remotes --> pkg_api_gateway - pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller - pkg_api_remotes --> pkg_api_workspace_controller - pkg_api_remotes --> pkg_commands - pkg_api_remotes --> pkg_cordis_host_runner - pkg_api_remotes --> pkg_credentials - pkg_api_remotes --> pkg_file_reference - pkg_api_remotes --> pkg_goal - pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants - pkg_api_remotes --> pkg_llm - pkg_api_remotes --> pkg_message_feedback - pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_reference - pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_subagent - pkg_api_remotes --> pkg_user_approval - pkg_api_remotes --> pkg_user_questions - pkg_client_ui_session --> pkg_api_session_controller - pkg_client_ui_session --> pkg_client_ui_renderer - pkg_client_ui_session --> pkg_invariants - pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants @@ -1334,7 +1344,6 @@ flowchart TD pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings pkg_client_ui_settings_models --> pkg_api_remotes - pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings @@ -1345,6 +1354,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1531,6 +1541,7 @@ flowchart TD pkg_client_ui_chat --> pkg_client_ui_layout pkg_client_ui_chat --> pkg_client_ui_renderer pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_settings pkg_client_ui_chat --> pkg_client_ui_workspace pkg_client_ui_chat --> pkg_commands pkg_client_ui_chat --> pkg_compaction @@ -1539,6 +1550,7 @@ flowchart TD pkg_client_ui_chat --> pkg_llm_retry pkg_client_ui_chat --> pkg_session pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools pkg_client_ui_chat --> pkg_util_workspace_path @@ -1553,12 +1565,15 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_api_session_controller + pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol + pkg_client_ui_reference --> pkg_util_workspace_path pkg_client_ui_subagent --> pkg_api_session_controller pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale @@ -1576,6 +1591,8 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_attachment + pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale pkg_session_log_export --> pkg_client_ui_commands pkg_session_log_export --> pkg_client_ui_conversation @@ -1583,12 +1600,16 @@ flowchart TD pkg_session_log_export --> pkg_client_ui_session pkg_session_log_export --> pkg_commands pkg_session_log_export --> pkg_invariants + pkg_session_log_export --> pkg_session + pkg_session_log_export --> pkg_session_persistence + pkg_session_log_export --> pkg_session_query pkg_client_ui_attachment --> pkg_attachment pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_api_remotes pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale pkg_client_ui_deliverables --> pkg_client_ui_chat @@ -1622,7 +1643,6 @@ flowchart TD pkg_client_ui_message_feedback --> pkg_typert_protocol pkg_client_ui_model_selection --> pkg_api_remotes pkg_client_ui_model_selection --> pkg_api_session_controller - pkg_client_ui_model_selection --> pkg_client_connection pkg_client_ui_model_selection --> pkg_client_locale pkg_client_ui_model_selection --> pkg_client_ui_commands pkg_client_ui_model_selection --> pkg_client_ui_conversation @@ -1750,10 +1770,11 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1781,9 +1802,8 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1866,21 +1886,23 @@ flowchart TD | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1890,27 +1912,25 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1930,17 +1950,17 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index cfea33efc0..5835824321 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -212,6 +212,7 @@ flowchart TD pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_experimental_inspector["experimental-inspector"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -231,7 +232,6 @@ flowchart TD pkg_tool_call_timeout_policy["tool-call-timeout-policy"] end subgraph group_host["packages/host"] - pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] @@ -429,6 +429,7 @@ flowchart TD pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout + pkg_llm --> pkg_typert_protocol pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_attachment_local --> pkg_invariants @@ -440,6 +441,9 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment + pkg_experimental_inspector --> pkg_client_modules + pkg_experimental_inspector --> pkg_host_webserver + pkg_experimental_inspector --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -538,16 +542,11 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants - pkg_api_settings_controller --> pkg_session - pkg_api_settings_controller --> pkg_settings - pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants - pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants @@ -1011,9 +1010,27 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval + pkg_api_settings_controller --> pkg_agent_presets + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_native_command + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_brand + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_credentials + pkg_client_connection --> pkg_host_directory_picker + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings + pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1027,8 +1044,6 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1090,17 +1105,11 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_directory_picker - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings - pkg_client_connection --> pkg_tool_todo + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1115,7 +1124,10 @@ flowchart TD pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title pkg_session_reference --> pkg_typert_protocol pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_instructions @@ -1139,10 +1151,13 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_invariants + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_invariants @@ -1202,11 +1217,39 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_api_session_controller --> pkg_agent + pkg_api_session_controller --> pkg_agent_default_model + pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_api_gateway + pkg_api_session_controller --> pkg_attachment + pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_invariants + pkg_api_session_controller --> pkg_jobs + pkg_api_session_controller --> pkg_llm + pkg_api_session_controller --> pkg_native_command + pkg_api_session_controller --> pkg_scope + pkg_api_session_controller --> pkg_session + pkg_api_session_controller --> pkg_session_persistence + pkg_api_session_controller --> pkg_session_projection + pkg_api_session_controller --> pkg_session_projection_cache + pkg_api_session_controller --> pkg_session_query + pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_skill + pkg_api_session_controller --> pkg_subagent + pkg_api_session_controller --> pkg_typert_protocol + pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_workspace_path + pkg_api_session_controller --> pkg_workspace + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1215,9 +1258,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1245,36 +1285,30 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_session_controller --> pkg_agent - pkg_api_session_controller --> pkg_agent_default_model - pkg_api_session_controller --> pkg_agent_presets - pkg_api_session_controller --> pkg_api_gateway - pkg_api_session_controller --> pkg_attachment - pkg_api_session_controller --> pkg_brand - pkg_api_session_controller --> pkg_client_connection - pkg_api_session_controller --> pkg_invariants - pkg_api_session_controller --> pkg_jobs - pkg_api_session_controller --> pkg_llm - pkg_api_session_controller --> pkg_scope - pkg_api_session_controller --> pkg_session - pkg_api_session_controller --> pkg_session_persistence - pkg_api_session_controller --> pkg_session_projection - pkg_api_session_controller --> pkg_session_projection_cache - pkg_api_session_controller --> pkg_session_query - pkg_api_session_controller --> pkg_session_title - pkg_api_session_controller --> pkg_subagent - pkg_api_session_controller --> pkg_typert_protocol - pkg_api_session_controller --> pkg_typert_registry - pkg_api_session_controller --> pkg_util_workspace_path - pkg_api_session_controller --> pkg_workspace - pkg_api_workspace_controller --> pkg_api_gateway - pkg_api_workspace_controller --> pkg_client_connection - pkg_api_workspace_controller --> pkg_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants - pkg_api_workspace_controller --> pkg_session - pkg_api_workspace_controller --> pkg_storage_domain - pkg_api_workspace_controller --> pkg_typert_protocol - pkg_api_workspace_controller --> pkg_workspace + pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller + pkg_api_remotes --> pkg_api_workspace_controller + pkg_api_remotes --> pkg_commands + pkg_api_remotes --> pkg_cordis_host_runner + pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_llm + pkg_api_remotes --> pkg_message_feedback + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_reference + pkg_api_remotes --> pkg_settings + pkg_api_remotes --> pkg_subagent + pkg_api_remotes --> pkg_user_approval + pkg_api_remotes --> pkg_user_questions + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1301,30 +1335,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_api_remotes --> pkg_agent_presets - pkg_api_remotes --> pkg_api_gateway - pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller - pkg_api_remotes --> pkg_api_workspace_controller - pkg_api_remotes --> pkg_commands - pkg_api_remotes --> pkg_cordis_host_runner - pkg_api_remotes --> pkg_credentials - pkg_api_remotes --> pkg_file_reference - pkg_api_remotes --> pkg_goal - pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants - pkg_api_remotes --> pkg_llm - pkg_api_remotes --> pkg_message_feedback - pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_reference - pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_subagent - pkg_api_remotes --> pkg_user_approval - pkg_api_remotes --> pkg_user_questions - pkg_client_ui_session --> pkg_api_session_controller - pkg_client_ui_session --> pkg_client_ui_renderer - pkg_client_ui_session --> pkg_invariants - pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants @@ -1336,7 +1346,6 @@ flowchart TD pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings pkg_client_ui_settings_models --> pkg_api_remotes - pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings @@ -1347,6 +1356,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1533,6 +1543,7 @@ flowchart TD pkg_client_ui_chat --> pkg_client_ui_layout pkg_client_ui_chat --> pkg_client_ui_renderer pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_settings pkg_client_ui_chat --> pkg_client_ui_workspace pkg_client_ui_chat --> pkg_commands pkg_client_ui_chat --> pkg_compaction @@ -1541,6 +1552,7 @@ flowchart TD pkg_client_ui_chat --> pkg_llm_retry pkg_client_ui_chat --> pkg_session pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools pkg_client_ui_chat --> pkg_util_workspace_path @@ -1555,12 +1567,15 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_api_session_controller + pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol + pkg_client_ui_reference --> pkg_util_workspace_path pkg_client_ui_subagent --> pkg_api_session_controller pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale @@ -1578,6 +1593,8 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_attachment + pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale pkg_session_log_export --> pkg_client_ui_commands pkg_session_log_export --> pkg_client_ui_conversation @@ -1585,12 +1602,16 @@ flowchart TD pkg_session_log_export --> pkg_client_ui_session pkg_session_log_export --> pkg_commands pkg_session_log_export --> pkg_invariants + pkg_session_log_export --> pkg_session + pkg_session_log_export --> pkg_session_persistence + pkg_session_log_export --> pkg_session_query pkg_client_ui_attachment --> pkg_attachment pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_api_remotes pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale pkg_client_ui_deliverables --> pkg_client_ui_chat @@ -1624,7 +1645,6 @@ flowchart TD pkg_client_ui_message_feedback --> pkg_typert_protocol pkg_client_ui_model_selection --> pkg_api_remotes pkg_client_ui_model_selection --> pkg_api_session_controller - pkg_client_ui_model_selection --> pkg_client_connection pkg_client_ui_model_selection --> pkg_client_locale pkg_client_ui_model_selection --> pkg_client_ui_commands pkg_client_ui_model_selection --> pkg_client_ui_conversation @@ -1752,10 +1772,11 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1783,9 +1804,8 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1868,21 +1888,23 @@ flowchart TD | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1892,27 +1914,25 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1932,17 +1952,17 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 13c2a19a3c..39462f81db 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: b9839833308afbd0d561bc227a95b236a10238a1 -persistence-catalog.zh.md: 7b5fa938b23ea9112e370133bf7575c8d689806d +persistence-catalog.md: 2143f7881edad36a986869fcbb458ae464014a44 +persistence-catalog.zh.md: abe6511a8ad9edbbc0e73ab77e02cf10d6346364 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b983983330..2143f7881e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -738,9 +738,9 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — log-only ```ts persistence-catalog /** @@ -749,10 +749,13 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` @@ -867,7 +870,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': CodeDispatchEventData +'tool/code-dispatch': PtcDispatchEventData ``` Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) @@ -890,7 +893,7 @@ Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': CodeDispatchStartEventData +'tool/code-dispatch-start': PtcDispatchStartEventData ``` Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7b5fa938b2..abe6511a8a 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -740,9 +740,9 @@ export type SessionEvent = { 来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — 仅日志 ```ts persistence-catalog /** @@ -751,10 +751,13 @@ export type SessionEvent = { * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` @@ -869,7 +872,7 @@ export type SessionEvent = { * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': CodeDispatchEventData +'tool/code-dispatch': PtcDispatchEventData ``` 来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) @@ -892,7 +895,7 @@ export type SessionEvent = { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': CodeDispatchStartEventData +'tool/code-dispatch-start': PtcDispatchStartEventData ``` 来源:[`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 56d92e2097..db0e53ba0e 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.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 docs/subsystems/client-modules.md -client-modules.md: 6188880fcd682c7c3c105764f212e92395444b61 -client-modules.zh.md: a732758492e1dbc9cbaef8d3effbd3310e4f10a1 +client-modules.md: f58cb6592a009292ffc4f87a207fe4e103fd2365 +client-modules.zh.md: 18c72dfba6f851776100a9b6200e7222d580db7c diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 6188880fcd..f58cb6592a 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -74,11 +74,11 @@ Each initial row's `rev` is an opaque process nonce plus sequence, so graph comp ## The scan -A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset. +A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Each live row resolves from its own Loader specifier and owning-tree `baseUrl`, through the same `loader.internal.resolveSync` implementation that imports its Host face when available. The nearest owning package manifest supplies the browser module id, so relative source and built overlays retain the package identity. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others. -Package metadata — including the negative "not a client package" verdict — is cached per name and never expires: plugin-set changes take effect on restart. A fiber restart reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`. +Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`. ## The bundle route and index injection diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index a732758492..18c72dfba6 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -74,11 +74,11 @@ interface WebBootGraph { ## 扫描 -包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。 +包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。每个 live row 都从自己的 Loader specifier 与所属 tree `baseUrl` 解析;若 `loader.internal.resolveSync` 可用,则使用 Host face import 所用的同一个实现。最近归属的 package manifest 提供浏览器模块 id,因此相对 source 与 built overlay 仍保留包身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。 扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。 -包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其行与 rev;bundle 内容变更只经 `rebuilt()` 到达图。 +包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启会原样复用其 row 与 rev;bundle 内容变更只经 `rebuilt()` 到达图。 ## bundle 路由与 index 注入 diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 97886685e5..228ed14b56 100644 --- a/docs/subsystems/code-runtime.i18n.yaml +++ b/docs/subsystems/code-runtime.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 docs/subsystems/code-runtime.md -code-runtime.md: ae760487eff19f6a0b91620b92007d0a86eb1589 -code-runtime.zh.md: b48e1a01b2817d9dafb25ac96673d00cf7d9ed08 +code-runtime.md: 0f633df9fc657d9d80fc04df3bc8ad6fafdddcb2 +code-runtime.zh.md: 43b78ce49575741f7ae6c4e2751b63b7562fc99a diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index ae760487ef..0f633df9fc 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -2,7 +2,7 @@ English | [中文](code-runtime.zh.md) -The code-execution seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) whose Service Definition ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread Service Provider and tool-registry Consumer are specified by the [Code Mode foundation](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [typed-return contract](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md). +The code-execution seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) whose Service Definition ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread Service Provider and tool-registry Consumer are specified by the [PTC mode foundation](../../.agents/notes/implemented/feature/2026-06-15-ptc.md) and [typed-return contract](../../.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md). Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -61,7 +61,7 @@ interface CodeRunResult { ## Bindings: host functions as program globals -Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A namespace may declare a program-visible error class without making the runtime know the consumer's names: the runtime injects the real constructor and turns rejected calls into its instances. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): +Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the PTC mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A namespace may declare a program-visible error class without making the runtime know the consumer's names: the runtime injects the real constructor and turns rejected calls into its instances. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): ```ts type-equiv /** @@ -69,7 +69,7 @@ Each `CodeBindingNamespace` becomes one global object of async callables inside * injects a real error constructor under `name`; rejected member calls become * its instances and expose the exact member name through * `memberNameProperty`. Both strings are runtime data rather than knowledge - * of a particular consumer such as Code Mode. + * of a particular consumer such as PTC mode. */ interface CodeBindingErrorClass { /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ diff --git a/docs/subsystems/code-runtime.zh.md b/docs/subsystems/code-runtime.zh.md index b48e1a01b2..43b78ce495 100644 --- a/docs/subsystems/code-runtime.zh.md +++ b/docs/subsystems/code-runtime.zh.md @@ -2,7 +2,7 @@ [English](code-runtime.md) | 中文 -代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md):其 Service Definition([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)使用宿主提供的异步绑定运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread Service Provider 与工具注册表 Consumer 的约定见 [Code Mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md) 和[类型化返回约定](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md)。 +代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md):其 Service Definition([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)使用宿主提供的异步绑定运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread Service Provider 与工具注册表 Consumer 的约定见 [PTC mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 和[类型化返回约定](../../.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md)。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -61,7 +61,7 @@ interface CodeRunResult { ## 绑定:宿主函数作为程序全局变量 -每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(Code Mode Consumer 传入一个:`tools`)。参数与返回值必须是无损 JSON,且跨越边界时不受 seam 层字节上限约束;运行时可以通过结构化克隆桥接它们。命名空间可以声明程序可见的错误类,而无需让运行时知道 Consumer 的名称:运行时会注入真实构造函数,并将被拒绝的调用转为该类的实例。运行时也将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): +每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(PTC mode Consumer 传入一个:`tools`)。参数与返回值必须是无损 JSON,且跨越边界时不受 seam 层字节上限约束;运行时可以通过结构化克隆桥接它们。命名空间可以声明程序可见的错误类,而无需让运行时知道 Consumer 的名称:运行时会注入真实构造函数,并将被拒绝的调用转为该类的实例。运行时也将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): ```ts type-equiv /** @@ -69,7 +69,7 @@ interface CodeRunResult { * injects a real error constructor under `name`; rejected member calls become * its instances and expose the exact member name through * `memberNameProperty`. Both strings are runtime data rather than knowledge - * of a particular consumer such as Code Mode. + * of a particular consumer such as PTC mode. */ interface CodeBindingErrorClass { /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ diff --git a/docs/subsystems/extensions.i18n.yaml b/docs/subsystems/extensions.i18n.yaml index e3a18d04d7..91f9f20574 100644 --- a/docs/subsystems/extensions.i18n.yaml +++ b/docs/subsystems/extensions.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 docs/subsystems/extensions.md -extensions.md: 0418afc7f1b6b6cd892deb618a4f346f6cde720d -extensions.zh.md: f2d86add9f1b62913fcc1b89abf1fc1a1d2a178f +extensions.md: 540f3c477b4e128b0c1062192185e6276c9e9263 +extensions.zh.md: ebfe7827484cea2cf8c6d77ca26796f7751d203a diff --git a/docs/subsystems/extensions.md b/docs/subsystems/extensions.md index 0418afc7f1..540f3c477b 100644 --- a/docs/subsystems/extensions.md +++ b/docs/subsystems/extensions.md @@ -256,6 +256,24 @@ Types: [Agent](core.md) Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts) + + +### `ctx.inspector` — `InspectorService` + +Shared Host/Client service façade over the realm's source publisher. + +```ts cordis-catalog +/** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ +publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +``` + +Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts) + ### `cordis/*` events diff --git a/docs/subsystems/extensions.zh.md b/docs/subsystems/extensions.zh.md index f2d86add9f..ebfe782748 100644 --- a/docs/subsystems/extensions.zh.md +++ b/docs/subsystems/extensions.zh.md @@ -256,6 +256,24 @@ Types: [Agent](core.zh.md) Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts) + + +### `ctx.inspector` — `InspectorService` + +Shared Host/Client service façade over the realm's source publisher. + +```ts cordis-catalog +/** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ +publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +``` + +Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts) + ### `cordis/*` events diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 7b91ec4729..19974d41f8 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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 docs/subsystems/llm-streaming.md -llm-streaming.md: 331c30e1039741462df7ae3e7a9e6497281a5df0 -llm-streaming.zh.md: f05c67bc7552a4bc5a9359416d98b62f238f1c65 +llm-streaming.md: e37356d91e75334987b6fc707744f2376df1b2fc +llm-streaming.zh.md: 05f24bed6beff9508db88e60af7b259eda33f474 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 331c30e103..e37356d91e 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -659,8 +659,6 @@ interface LlmModelDiscoveryRequest { api?: string /** Credential for this interrogation alone; the harness never stores it. */ apiKey?: string - /** Caller cancellation; implementations must settle promptly after it aborts. */ - signal?: AbortSignal } ``` @@ -883,7 +881,7 @@ registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHa * Describe provider routes with a registered adapter. * @returns detached provider metadata in registration order. */ -listProviders(): LlmProviderInfo[] +@Remote listProviders(): LlmProviderInfo[] /** * Declare provider routes an adapter plugin can activate through @@ -899,7 +897,7 @@ registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): Dire * List every declared configurable provider, registered or dormant. * @returns detached directory entries in declaration order. */ -listConfigurableProviders(): LlmConfigurableProvider[] +@Remote listConfigurableProviders(): LlmConfigurableProvider[] /** * Offer to interrogate provider endpoints on behalf of the settings @@ -908,10 +906,10 @@ listConfigurableProviders(): LlmConfigurableProvider[] * directory, and because a provider being *added* has no route to name yet. * Disposed with the fiber. * @param settingsNs - the namespace whose profiles this discovery serves. - * @param discover - interrogates one endpoint; must honor `request.signal`. + * @param discover - interrogates one endpoint and must honor the supplied signal. * @returns the disposer that withdraws the offer. */ -registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void +registerModelDiscovery( settingsNs: string, discover: ( request: LlmModelDiscoveryRequest, signal?: AbortSignal, ) => Promise, ): () => void /** * Interrogate one provider endpoint for the models it advertises. The @@ -920,9 +918,20 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover * candidate metadata a surface may offer for adoption. * @param settingsNs - namespace whose registered discovery serves this draft. * @param request - the endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation. * @returns the advertised models, deduplicated in endpoint order. */ -async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise +async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal, ): Promise + +/** + * Remote adapter for one draft provider interrogation. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation supplied by the Remote carrier. + * @returns advertised models in endpoint order. + * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + */ +@Remote('discoverModels') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise /** * Resolve the retry policy captured when one provider route was registered. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index f05c67bc75..05f24bed6b 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -665,8 +665,6 @@ interface LlmModelDiscoveryRequest { api?: string /** Credential for this interrogation alone; the harness never stores it. */ apiKey?: string - /** Caller cancellation; implementations must settle promptly after it aborts. */ - signal?: AbortSignal } ``` @@ -889,7 +887,7 @@ registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHa * Describe provider routes with a registered adapter. * @returns detached provider metadata in registration order. */ -listProviders(): LlmProviderInfo[] +@Remote listProviders(): LlmProviderInfo[] /** * Declare provider routes an adapter plugin can activate through @@ -905,7 +903,7 @@ registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): Dire * List every declared configurable provider, registered or dormant. * @returns detached directory entries in declaration order. */ -listConfigurableProviders(): LlmConfigurableProvider[] +@Remote listConfigurableProviders(): LlmConfigurableProvider[] /** * Offer to interrogate provider endpoints on behalf of the settings @@ -914,10 +912,10 @@ listConfigurableProviders(): LlmConfigurableProvider[] * directory, and because a provider being *added* has no route to name yet. * Disposed with the fiber. * @param settingsNs - the namespace whose profiles this discovery serves. - * @param discover - interrogates one endpoint; must honor `request.signal`. + * @param discover - interrogates one endpoint and must honor the supplied signal. * @returns the disposer that withdraws the offer. */ -registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void +registerModelDiscovery( settingsNs: string, discover: ( request: LlmModelDiscoveryRequest, signal?: AbortSignal, ) => Promise, ): () => void /** * Interrogate one provider endpoint for the models it advertises. The @@ -926,9 +924,20 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover * candidate metadata a surface may offer for adoption. * @param settingsNs - namespace whose registered discovery serves this draft. * @param request - the endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation. * @returns the advertised models, deduplicated in endpoint order. */ -async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise +async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal, ): Promise + +/** + * Remote adapter for one draft provider interrogation. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation supplied by the Remote carrier. + * @returns advertised models in endpoint order. + * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + */ +@Remote('discoverModels') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise /** * Resolve the retry policy captured when one provider route was registered. diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index b1a9db8e25..37a91f5c75 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.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 docs/subsystems/schedule.md -schedule.md: a0a35569dc1be87553b341323b98dfb122baee16 -schedule.zh.md: a10f9a5f5eac66bb211ac8f6a1cfa1570fc36e7c +schedule.md: f98cf319d588f805e4630ca572f10c3c45e94b79 +schedule.zh.md: bee816be9cc49943f5815d4d13869b27e30380d5 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index a0a35569dc..f98cf319d5 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -2,7 +2,7 @@ English | [中文](schedule.zh.md) -Schedule owns durable reminders that return to the original live Session as ordinary later conversation turns. The [durable Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md) owns the persistence and lifecycle decisions, [conversational delivery](../../.agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.md) owns the no-receipt boundary, the [read-only Web catalog](../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md) owns active-state presentation, the [explicit time-zone boundary](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) owns browser-local interpretation, and [bounded fixed-rate Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.md) owns recurrence. This page records the durable and model-facing shapes from [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts); the [package README](../../packages/schedule/schedule/README.md) owns composition, tool behavior, and the exact reminder framing. +Schedule owns durable reminders that return to the original live Session as ordinary later conversation turns. The [durable Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md) owns persistence, lifecycle, and active-state presentation, [conversational delivery](../../.agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.md) owns the no-receipt boundary, the [explicit time-zone boundary](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) owns browser-local interpretation, and [bounded fixed-rate Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.md) owns recurrence. This page records the durable and model-facing shapes from [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts); the [package README](../../packages/schedule/schedule/README.md) owns composition, tool behavior, and the exact reminder framing. ## Durable records @@ -149,7 +149,7 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -The strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. A normal Session folds its complete event stream. A fork folds only events at or after `SessionHeader.seedLength`, so it retains history without adopting the parent Session's active reminders. The Schedule projection derives that boundary from the immutable header passed to `init(header)`, uses the shared transition, and persists both active records and used-id history so cached restore preserves strict replay; the registry validates the boundary against the observed log. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). +The strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. A normal Session folds its complete event stream. A fork folds only events at or after `SessionHeader.seedLength`, so it retains history without adopting the parent Session's active reminders. The Schedule projection derives that boundary from the immutable header passed to `init(header)`, uses the shared transition, and persists both active records and used-id history so cached restore preserves strict replay. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). ## Active views and management @@ -181,7 +181,7 @@ The generated [tool catalog](../tool-catalog.md#deepseek-aidsh-schedule) owns th When the optional Session projection registry is present, Schedule registers the client-visible `schedule` key whose value is the complete active `ScheduleRecord[]`. Live, cache, history, and detached reads use the same header-aware strict fold; malformed authoritative input fails the existing read path instead of publishing a partial value. -The shipped Web bundle keeps `ui-schedule` disabled by default, while the explicit Schedule overlay enables it together with the Host capability. [`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.md), [`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.md), and the [catalog decision](../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md) own the presentation contracts. The shared value represents current active state, never delivery history or a receipt; due reminders still appear through the ordinary Assistant output described below. +The shipped Web bundle keeps `ui-schedule` disabled by default, while the explicit Schedule overlay enables it together with the Host capability. [`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.md) owns the header interaction, [`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.md) owns list-row presentation, and the durable Schedule Agent Note owns their shared active-state boundary. The shared value represents current active state, never delivery history or a receipt; due reminders still appear through the ordinary Assistant output described below. ## Live delivery diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index a10f9a5f5e..bee816be9c 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -2,7 +2,7 @@ [English](schedule.md) | 中文 -Schedule 拥有持久提醒;这些提醒会作为普通的后续对话轮次返回原 live Session。[持久 Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md) 负责持久化与生命周期决策,[对话式交付](../../.agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.zh.md) 负责无回执边界,[只读 Web 目录](../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md)负责活动状态呈现,[显式时区边界](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md) 负责浏览器本地解释,[有界固定速率 Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.zh.md) 负责重复调度。本页记录 [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久数据形状和面向模型的数据形状;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合、工具行为与确切的提醒 framing。 +Schedule 拥有持久提醒;这些提醒会作为普通的后续对话轮次返回原 live Session。[持久 Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md) 负责持久化、生命周期与活动状态呈现,[对话式交付](../../.agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.zh.md) 负责无回执边界,[显式时区边界](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md) 负责浏览器本地解释,[有界固定速率 Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.zh.md) 负责重复调度。本页记录 [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久数据形状和面向模型的数据形状;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合、工具行为与确切的提醒 framing。 ## 持久记录 @@ -149,7 +149,7 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠 `SessionHeader.seedLength` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Schedule projection 从传给 `init(header)` 的不可变 header 派生该边界,复用共享 transition,并持久化活动记录与已使用 id 历史,使缓存恢复继续保持严格回放;注册表会对照已观察日志校验边界。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。 +严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠 `SessionHeader.seedLength` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Schedule projection 从传给 `init(header)` 的不可变 header 派生该边界,复用共享 transition,并持久化活动记录与已使用 id 历史,使缓存恢复继续保持严格回放。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。 ## 活动视图与管理 @@ -181,7 +181,7 @@ type ScheduleView = ScheduleRecord & { 可选 Session projection 注册表存在时,Schedule 会注册客户端可见的 `schedule` key,其值是完整的活动 `ScheduleRecord[]`。live、cache、history 与 detached 读取共用同一套 header-aware 严格 fold;畸形权威输入会使既有读取路径失败,而不会发布部分值。 -shipped Web bundle 默认禁用 `ui-schedule`,显式 Schedule overlay 则把它与 Host 能力一同启用。[`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.zh.md)、[`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.zh.md)与[目录决策](../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md)分别拥有呈现合同。共享值只表示当前活动状态,绝不表示交付历史或回执;到期提醒仍通过下文所述的普通 Assistant 输出出现。 +shipped Web bundle 默认禁用 `ui-schedule`,显式 Schedule overlay 则把它与 Host 能力一同启用。[`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.zh.md)拥有 header 交互,[`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.zh.md)拥有列表行呈现,持久 Schedule Agent Note 拥有二者共享的活动状态边界。共享值只表示当前活动状态,绝不表示交付历史或回执;到期提醒仍通过下文所述的普通 Assistant 输出出现。 ## Live 交付 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 176f6fab80..4bdbc164e8 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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 docs/subsystems/session-projection.md -session-projection.md: 87a11475bd12ed922030f08ae5d164641b95ec09 -session-projection.zh.md: 7af0cfc7e9eba696d3bf826de6b0e4067a245aaf +session-projection.md: fd40408c380a3694c6b91b9ad79417cd0feb738b +session-projection.zh.md: 392a537416bc7968e04997724e97512db4c6232d diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 87a11475bd..fd40408c38 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -63,7 +63,7 @@ interface ProjectionDefinition< } ``` -The load-bearing rule is a deterministic synchronous fold with a complete wire value. A domain may own whole-value events or incremental transitions, but it validates and folds them on the Host; clients never replay those events or receive a delta. `init(header)` receives the immutable `SessionHeader` that accompanies the observed events, and the registry rejects a normalized `header.seedLength ?? 0` beyond that log. Definitions may interpret relevant immutable fields but never consult ambient mutable state. +The whole-value event rule is load-bearing: a state-carrying log event carries the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). ## The snapshot and the change feed @@ -99,7 +99,7 @@ type ProjectionChangeListener = ( ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — for a unit registered after events flowed, or a session older than the registry, the registry validates the normalized seed boundary and then passes the immutable `SessionHeader` to the unit's sole `init(header)` call before the in-memory log folds on first touch (event or read). Detached cache, history, and Subagent restore paths pass the immutable header returned with the same persisted event read to that same initializer. Registration is an effect whose disposer rides the calling fiber: a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted; the key and its cells disappear after the last registrant unloads. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. @@ -118,11 +118,12 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), with the lowest served watermark carried - * for later authoritative reconciliation. The caller's header keeps unrelated - * lifecycles out; repeated list blocks are arrival-ordered tentative hints - * because crash repair may lower the durable sequence; authoritative frames - * replace matching rows, and complete baselines replace the full set. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can seed under its higher-seq-wins rule — as stale + * as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline) supersede these values whenever a + * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or @@ -273,11 +274,10 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or - * absent rows leave their key absent. The current log extent is unknown, so - * returned values are tentative hints: a row may trail the log or overreach - * a crash-repaired truncation. Exact restore validates the cut before using - * a row as authoritative state. + * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 7af0cfc7e9..392a537416 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -63,7 +63,7 @@ interface ProjectionDefinition< } ``` -承重规则是确定性同步 fold 与完整 wire 值。领域可以拥有全量值事件,也可以拥有增量 transition,但它会在 Host 上校验并折叠这些事件;客户端既不回放这些事件,也不会收到 delta。`init(header)` 接收与已观察事件配套的不可变 `SessionHeader`,注册表会拒绝超过该日志长度的规范化 `header.seedLength ?? 0`。definition 可以解释相关的不可变字段,但不能读取环境中的可变状态。 +全量值事件规则是承重结构:携带状态的日志事件携带的是变更后的完整状态,绝不是裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 ## 快照与变更流 @@ -99,7 +99,7 @@ type ProjectionChangeListener = ( ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:对于在事件流过之后才注册的单元,或比注册表更早的会话,注册表会先校验规范化的 seed 边界,再通过唯一一次 `init(header)` 调用把不可变的 `SessionHeader` 传给单元,随后在首次触达(事件或读取)时折叠内存日志。detached cache、history 与 Subagent restore 路径把与持久事件同一次读取返回的不可变 header 传给同一个初始化器。注册是一个 effect,其 disposer 随调用方 fiber 走:同一 key 以不同 `stateVersion` 重复注册时抛错,同版本注册方则共享一个单元并计数;最后一个注册方卸载后,该 key 与其 cell 才会消失。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 @@ -118,11 +118,12 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), with the lowest served watermark carried - * for later authoritative reconciliation. The caller's header keeps unrelated - * lifecycles out; repeated list blocks are arrival-ordered tentative hints - * because crash repair may lower the durable sequence; authoritative frames - * replace matching rows, and complete baselines replace the full set. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can seed under its higher-seq-wins rule — as stale + * as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline) supersede these values whenever a + * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or @@ -273,11 +274,10 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or - * absent rows leave their key absent. The current log extent is unknown, so - * returned values are tentative hints: a row may trail the log or overreach - * a crash-repaired truncation. Exact restore validates the cut before using - * a row as authoritative state. + * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 886a442517..29ba36ab67 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.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 docs/subsystems/session-reference.md -session-reference.md: 17c0d14485bd5a0eea97781c8f5fe1c4b3bda886 -session-reference.zh.md: 31a94bf94442a3778763457869bf916e18964a5c +session-reference.md: 1dd5cc1ee8c594b34015f9bf2d765f68621b86a1 +session-reference.zh.md: 75b5018a6afbd1bbe21f303013e0e7fa0d1f89ab diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 17c0d14485..1dd5cc1ee8 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -34,7 +34,7 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, while filtering still searches only session id and cwd and never transcript text. +`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, and filtering searches that label alongside session id and cwd, never transcript text. ```ts type-equiv /** One host-facing candidate from exact session metadata. */ @@ -45,6 +45,12 @@ interface SessionReferenceCandidate { label: string /** Source session working directory, when recorded. */ cwd?: string + /** + * True when {@link SessionReferenceCandidate.cwd} is recorded and equals the + * requesting agent's. Hosts that only surface a distinguishing location + * read this instead of comparing paths they never received. + */ + sameWorkspace: boolean /** Source session creation time in Unix epoch milliseconds. */ createdAt: number } @@ -113,22 +119,33 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise - -/** - * Remote face of {@link list}; the decorator cannot mark the abstract - * member, so this concrete adapter carries the identical contract. - * @param agent - target agent whose session cwd bounds discovery. - * @param query - path text following `@` or `@"`. - * @param signal - caller cancellation. - * @returns deterministic path-only candidates. - */ -@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.md) Source: [`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) + + +### `ctx.sessionFileReferences` — `SessionFileReferences` + +Host Remote adapter over the composed file-reference provider. + +```ts cordis-catalog +/** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ +@Remote list( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/api/session-controller/src/file-references.ts`](../../packages/api/session-controller/src/file-references.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` @@ -138,6 +155,10 @@ Exact-read consumer that prepares immutable cross-session message context. ```ts cordis-catalog /** * List reference candidates, ranked by working-directory affinity. + * + * Discovery runs at keystroke rate, so a title only ever comes from a + * projection read: see {@link SessionReferenceResolver.projectedTitle} for + * which sessions can answer one and which fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 31a94bf944..75b5018a6a 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -34,7 +34,7 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选仍只搜索 session id 和 cwd,绝不搜索 transcript(文本记录)。 +`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选搜索该 label 以及 session id 和 cwd,绝不搜索 transcript(文本记录)。 ```ts type-equiv /** One host-facing candidate from exact session metadata. */ @@ -45,6 +45,12 @@ interface SessionReferenceCandidate { label: string /** Source session working directory, when recorded. */ cwd?: string + /** + * True when {@link SessionReferenceCandidate.cwd} is recorded and equals the + * requesting agent's. Hosts that only surface a distinguishing location + * read this instead of comparing paths they never received. + */ + sameWorkspace: boolean /** Source session creation time in Unix epoch milliseconds. */ createdAt: number } @@ -113,22 +119,33 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise - -/** - * Remote face of {@link list}; the decorator cannot mark the abstract - * member, so this concrete adapter carries the identical contract. - * @param agent - target agent whose session cwd bounds discovery. - * @param query - path text following `@` or `@"`. - * @param signal - caller cancellation. - * @returns deterministic path-only candidates. - */ -@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.zh.md) Source: [`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) + + +### `ctx.sessionFileReferences` — `SessionFileReferences` + +Host Remote adapter over the composed file-reference provider. + +```ts cordis-catalog +/** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ +@Remote list( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/api/session-controller/src/file-references.ts`](../../packages/api/session-controller/src/file-references.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` @@ -138,6 +155,10 @@ Exact-read consumer that prepares immutable cross-session message context. ```ts cordis-catalog /** * List reference candidates, ranked by working-directory affinity. + * + * Discovery runs at keystroke rate, so a title only ever comes from a + * projection read: see {@link SessionReferenceResolver.projectedTitle} for + * which sessions can answer one and which fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 80666a3c88..f459ae1fd9 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: 87e8b33dec8eda6f7a716d71f5c4afa69a420d35 -session.zh.md: d30b7d24412a04521300c657c1b07daa1620ca5f +session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 +session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 87e8b33dec..f3c246f7a7 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -577,6 +577,12 @@ What a persistence backend relies on: the durable log persists every event lossl The backends that consume this contract are on [persistence.md](persistence.md). +## Remote catalog and workspace opening + +`ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. + +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. + @@ -637,6 +643,27 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise +/** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ +@Remote('modelCatalog') modelCatalog(): Promise + +/** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ +@Remote canOpenWorkspacePath(): boolean + +/** + * Open one path prepared by a Session-aware caller on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. + * @returns confirmation after the native opener accepts the path. + * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + */ +@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d30b7d2441..bc706a11e9 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -581,6 +581,12 @@ interface TurnEndReasonMap { 消费此约定的后端见 [persistence.md](persistence.zh.md)。 +## Remote 目录与 workspace 打开 + +`ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。 + +`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 + @@ -641,6 +647,27 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise +/** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ +@Remote('modelCatalog') modelCatalog(): Promise + +/** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ +@Remote canOpenWorkspacePath(): boolean + +/** + * Open one path prepared by a Session-aware caller on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. + * @returns confirmation after the native opener accepts the path. + * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + */ +@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 0c345cf749..2efb877591 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.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 docs/subsystems/settings.md -settings.md: 59bd06a6e7a8659097c726b85db13caa46dd461f -settings.zh.md: a6bbdbce633a8b51167615f7838e55b698d48829 +settings.md: 3215de191b4ef8fecb373d280c8bc8e4a89bbc7e +settings.zh.md: 0c28ef381ee6ea8e59da64717575fbf80c8a2e5f diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 59bd06a6e7..3215de191b 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -161,6 +161,10 @@ Every committed change — an in-process write or an externally observed provide type SettingsUpdateSource = 'update' | 'provider' ``` +## Native document operations + +`SettingsDocumentOpenValue` confirms that `settings/openSettingsDocument` prepared the provider-owned document and handed it to the native text editor. `AgentPresetDirectoryOpenValue` reports either a completed native handoff or the resolved user-preset directory when desktop opening is unavailable. Neither operation accepts a browser-selected Host path. + @@ -269,6 +273,12 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue +/** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ +@Remote canOpenAgentPresetDirectory(): boolean + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. @@ -300,6 +310,23 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise + +/** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + */ +@Remote async openSettingsDocument(signal: AbortSignal): Promise + +/** + * Open one user-authored Agent preset directory or return its path when no native opener exists. + * @param agentPreset - preset id resolved against Host-owned roots. + * @param signal - caller lifetime; abort terminates the native command. + * @returns an opened confirmation or the resolved directory for text display. + * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + */ +@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index a6bbdbce63..0c28ef381e 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -161,6 +161,10 @@ interface SettingsDescribeOptions { type SettingsUpdateSource = 'update' | 'provider' ``` +## 原生文档操作 + +`SettingsDocumentOpenValue` 确认 `settings/openSettingsDocument` 已准备好 provider 持有的文档,并将其交给原生文本编辑器。`AgentPresetDirectoryOpenValue` 报告已完成的原生交接,或在桌面打开不可用时返回解析后的用户 preset 目录。两项操作都不接受由浏览器选择的 Host 路径。 + @@ -269,6 +273,12 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue +/** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ +@Remote canOpenAgentPresetDirectory(): boolean + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. @@ -300,6 +310,23 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise + +/** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + */ +@Remote async openSettingsDocument(signal: AbortSignal): Promise + +/** + * Open one user-authored Agent preset directory or return its path when no native opener exists. + * @param agentPreset - preset id resolved against Host-owned roots. + * @param signal - caller lifetime; abort terminates the native command. + * @returns an opened confirmation or the resolved directory for text display. + * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + */ +@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index 93efc1068d..7bf2ead73e 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.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 docs/subsystems/skills.md -skills.md: 5abfa84356dc947f8b3359ad5f0fc5c42a99553a -skills.zh.md: 87759b5ab5bdfc5e9ab10cf9a86147c16893efd3 +skills.md: 18eb4d3289cec5c7807feab4229096b5799d4168 +skills.zh.md: aa167373cb9736cf24d7bf20dc965e68568826be diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 5abfa84356..18eb4d3289 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -234,6 +234,10 @@ Before each later model step, the consumer applies exact tool visibility and dig The model-facing `skill({ name })` tool validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless `isModelInvocable` permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content. It reports an unresolved skill as unknown or no longer available and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. +## Browser Session catalog + +`SkillListRequest` addresses one Session by `sessionId`; `SkillListValue` returns the user-invocable entries with name, description, optional usage guidance, and model-invocation availability. `SessionSkillCatalog` reads the Session cwd and recorded preset without activating an Agent. A live Agent may supply its scoped registry, while a cold Session uses the preset's standing scope. + @@ -242,6 +246,25 @@ The model-facing `skill({ name })` tool validates the kebab-case name, finds the Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionSkillCatalog` — `SessionSkillCatalog` + +Host service backing `ctx.remote.skills` without activating a cold Agent. + +```ts cordis-catalog +/** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + */ +@Remote async list(request: SkillListRequest, signal: AbortSignal): Promise +``` + +Source: [`packages/api/session-controller/src/skill-catalog.ts`](../../packages/api/session-controller/src/skill-catalog.ts) + ### `ctx.skills` — `SkillRegistry` diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index 87759b5ab5..aa167373cb 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -234,6 +234,10 @@ interface Config { 面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它根据调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将无法解析的 skill 报告为未知或已不可用,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 +## 浏览器 Session 目录 + +`SkillListRequest` 通过 `sessionId` 指定一个 Session;`SkillListValue` 返回允许用户调用的条目,其中包含名称、描述、可选使用提示与模型调用可用性。`SessionSkillCatalog` 在不激活 Agent 的前提下读取 Session cwd 与记录的 preset。live Agent 可以提供其作用域 registry,冷 Session 则使用 preset 的 standing scope。 + @@ -242,6 +246,25 @@ interface Config { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionSkillCatalog` — `SessionSkillCatalog` + +Host service backing `ctx.remote.skills` without activating a cold Agent. + +```ts cordis-catalog +/** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + */ +@Remote async list(request: SkillListRequest, signal: AbortSignal): Promise +``` + +Source: [`packages/api/session-controller/src/skill-catalog.ts`](../../packages/api/session-controller/src/skill-catalog.ts) + ### `ctx.skills` — `SkillRegistry` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f4414983b1..9e82c64ceb 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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 docs/subsystems/subagent.md -subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f -subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468 +subagent.md: 9206672b89ac31e30bee176f536b3057eb37f3ee +subagent.zh.md: 30e4e09a145a4b3d51044d85c602ce81fd44c3b3 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index f854711f11..9206672b89 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentEnabled(): boolean +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 960d9b099d..30e4e09a14 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentEnabled(): boolean +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 3de50c917a..d448297059 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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 docs/subsystems/tools.md -tools.md: 5ac2401d662bca52cac529c0620a39930565a7c7 -tools.zh.md: a081c58a2ba4df50b035a4dae8fa7f9ab7d9de1a +tools.md: 9939e8ab9fff9fa5bd23fd370e07f6296a608824 +tools.zh.md: 52e812a35d6932a5ed00a86a3d3fe71460b26cd4 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 5ac2401d66..9939e8ab9f 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -157,7 +157,7 @@ Registration is a trusted same-process contract. The registry borrows the typed ```ts type-equiv /** * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * scoped registrations or the reserved PTC mode transport. */ interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -195,11 +195,11 @@ interface ToolExecutionInput { /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent /** - * Opaque token of the enclosing transport execution, when one exists. Code - * Mode sets this on SDK sub-dispatches so commit-style observers can wait for + * Opaque token of the enclosing transport execution, when one exists. PTC + * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'code'`, only calls WITH a parent may + * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may * execute a native tool name — a model-direct call (no parent) is denied as * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. */ @@ -252,19 +252,19 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): +PTC mode's bridge additionally exposes each settled sub-dispatch to the `tools/ptc-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): ```ts type-equiv /** * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the * `tool/code-dispatch` event's copy changes. */ -interface CodeDispatchLog { +interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ @@ -488,7 +488,7 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v * declaration covers every agent joined under it. * * Scoped only, and one declaration per scope: this is how an agent preset - * composes Code Mode agents beside native ones in the same process, and a + * composes PTC mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. @@ -598,33 +598,6 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - - -#### `tools/code-dispatch-log` — waterfall - -Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - -```ts cordis-catalog -/** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/code-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall - */ -'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise -``` - -Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) - -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - #### `tools/execute` — waterfall @@ -697,6 +670,33 @@ Types: [Scoped](scope.md) Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + + +#### `tools/ptc-dispatch-log` — waterfall + +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +``` + +Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) + +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + #### `tools/result` — emit diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index a081c58a2b..52e812a35d 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -157,7 +157,7 @@ type InferArgs = InferProperties ```ts type-equiv /** * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * scoped registrations or the reserved PTC mode transport. */ interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -195,11 +195,11 @@ interface ToolExecutionInput { /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent /** - * Opaque token of the enclosing transport execution, when one exists. Code - * Mode sets this on SDK sub-dispatches so commit-style observers can wait for + * Opaque token of the enclosing transport execution, when one exists. PTC + * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'code'`, only calls WITH a parent may + * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may * execute a native tool name — a model-direct call (no parent) is denied as * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. */ @@ -252,19 +252,19 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): +PTC mode 的桥接层还会把每个已结算的子分派暴露给 `tools/ptc-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): ```ts type-equiv /** * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the * `tool/code-dispatch` event's copy changes. */ -interface CodeDispatchLog { +interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ @@ -488,7 +488,7 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v * declaration covers every agent joined under it. * * Scoped only, and one declaration per scope: this is how an agent preset - * composes Code Mode agents beside native ones in the same process, and a + * composes PTC mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. @@ -598,33 +598,6 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - - -#### `tools/code-dispatch-log` — waterfall - -Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - -```ts cordis-catalog -/** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/code-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall - */ -'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise -``` - -Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) - -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - #### `tools/execute` — waterfall @@ -697,6 +670,33 @@ Types: [Scoped](scope.zh.md) Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + + +#### `tools/ptc-dispatch-log` — waterfall + +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +``` + +Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) + +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + #### `tools/result` — emit diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 598956872e..498532ed69 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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 docs/subsystems/typert.md -typert.md: bf43280f7fa01e6300caeaadeadb2c6c8f78cdd2 -typert.zh.md: c50663ad2546d864efc5648059dde735ae4de5bd +typert.md: 20734175cc6b853ae7eeb18e6d5fc91a97cdbe0d +typert.zh.md: e900011f24a5a232ab8764501594b563f320e090 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index bf43280f7f..20734175cc 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -185,9 +185,13 @@ interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -238,14 +242,6 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.apiProxy` — `ApiProxy` - -Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) - ### `ctx.typert` — `TypertRegistry` @@ -322,9 +318,10 @@ Resolve strict generated definitions or conservative SRC markers against current /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ -registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise /** * Invoke one live Remote method through strict generated reflection or SRC markers. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index c50663ad25..e900011f24 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -185,9 +185,13 @@ interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -238,14 +242,6 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.apiProxy` — `ApiProxy` - -Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) - ### `ctx.typert` — `TypertRegistry` @@ -322,9 +318,10 @@ Resolve strict generated definitions or conservative SRC markers against current /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ -registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise /** * Invoke one live Remote method through strict generated reflection or SRC markers. diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 98e549a149..5a83be6c89 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.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 docs/subsystems/web-client.md -web-client.md: e67da4980af881e426f2bc031c6ec49cc1df2857 -web-client.zh.md: c5e9904226de66381a54bcb8a4783a062d70750f +web-client.md: 166ad50df661e37318c5ed2f271569c292cce39a +web-client.zh.md: cdf91958e23c0ea5c99562e6ca347947fbeff292 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index e67da4980a..166ad50df6 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -27,9 +27,9 @@ The Web boot kernel creates the module system, prefetches `immediately` entries, Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. -The Connection owns request correlation, the `/api` carrier, trust checks, Host description, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. API Proxy handles only `/api` endpoints that no strict Remote descriptor claims; new controller operations belong on generated Remote methods or explicit Remote streams. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. +The Connection owns request correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. -The internal `$events` logical stream is the Connection generation source. A generation becomes connected only after the event source emits `ready` and `host.describe` succeeds. Host listeners are therefore attached before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. +The internal `$events` logical stream is the Connection generation source. Its opening `ready` frame carries the Host home used for path display and establishes the generation after Host listeners are attached, before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. ## Client models diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index c5e9904226..cdf91958e2 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -27,9 +27,9 @@ Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendore Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 -Connection 拥有 request correlation、`/api` carrier、trust check、Host description 与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。API Proxy 只处理没有被严格 Remote descriptor 认领的 `/api` endpoint;新的 controller 操作应进入生成的 Remote method 或显式 Remote stream。[API Gateway 参考](../api-gateway.zh.md)定义生成与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 +Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 -内部 `$events` logical stream 是 Connection generation source。只有 event source 发出 `ready` 且 `host.describe` 成功后,一代 connection 才会进入 connected。Host listener 因而先于任何 controller baseline read 挂载。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 +内部 `$events` logical stream 是 Connection generation source。它的 opening `ready` frame 携带用于路径显示的 Host home,并在 Host listener 已挂载、任何 controller 开始 baseline read 之前建立 generation。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 ## Client models diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 87cca8b0b2..bec49cab10 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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 docs/subsystems/web-server.md -web-server.md: b806f5b40a2f5ddada40752367e3da23e86ea13d -web-server.zh.md: e5f4a8794d46a2b55d4a69e2cdd13c7dc4c2a6dd +web-server.md: d9b1ec007bc73274bb0c8b5d58745da1c76e6cb6 +web-server.zh.md: 66375534a89e2c4674e748d4178cb7a31761e57e diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index b806f5b40a..d9b1ec007b 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index e5f4a8794d..66375534a8 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 5b23decb3c..392f448802 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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 docs/tool-catalog.md -tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622 -tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819 +tool-catalog.md: 91ff093e79cc05a2c08b5aa1130378440cd963f3 +tool-catalog.zh.md: 35daf5a7c6b1c27975721c0b9d8ddfe71eaeb803 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7b166243fc..91ff093e79 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -144,9 +144,9 @@ Execute a TypeScript program against the available tools. Takes two required arg } ``` -Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) -Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. +Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. @@ -1561,7 +1561,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. ```json { @@ -1575,18 +1575,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1601,7 +1589,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index b44a0de496..35daf5a7c6 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -20,7 +20,7 @@ | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: code`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 Code Mode Agent Note)。在 `code` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -148,9 +148,9 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 } ``` -来源:[`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) +来源:[`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) -在 `mode: code`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 Code Mode Agent Note)。在 `code` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 +在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 @@ -1567,7 +1567,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 ```json { @@ -1581,18 +1581,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1607,7 +1595,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index bd8de3ba60..6ff5ef0a15 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.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 docs/tool-execution-pipeline.md -tool-execution-pipeline.md: d04d2e4e5093fee92f8921f0eb0112c960a81bb8 -tool-execution-pipeline.zh.md: 15627023d3be6ac2b3aae70c2ef01ef9f1077d3e +tool-execution-pipeline.md: a799c68a60f6782ef3bb79c52f89cbc78d762ab3 +tool-execution-pipeline.zh.md: 7ffdd34298f630c975b23f23daf875a3c4cdc84d diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index d04d2e4e50..a799c68a60 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -57,6 +57,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 15627023d3..7ffdd34298 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -59,6 +59,6 @@ flowchart TD allResults --> context ``` -文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。Code Mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/code-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 +文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。PTC mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/code-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 维护模式:英文源文件包含人工维护的 Mermaid 流程图,并由生成器写出;本中文文件作为经评审对侧通过双语配对维护。确切的工具 schema 与事件签名位于生成的目录中。 diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 99fdfe2139..0c7c5bbc82 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/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 docs/user/develop/basic/tool.md -tool.md: 24a82d277e626ba78760a0afb1c41e2a157eb8ee -tool.zh.md: a07bef588f5a18093cf2eb1971b855bf40d1d0b6 +tool.md: aeed5cf0e742cdde33bdcb86bfac4de471ae2597 +tool.zh.md: 25538e42b7777fe3b05b361379a8bce70d6ec5f5 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 24a82d277e..aeed5cf0e7 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -48,5 +48,5 @@ Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The mod ## Next steps - [Plugin configuration](./config.md) — make the greeting configurable. -- [Tool authoring reference](../../../cookbook/adding-a-tool.md) — look up nested schemas, canonical values, background work, policy hooks, Code Mode, and UI cards. +- [Tool authoring reference](../../../cookbook/adding-a-tool.md) — look up nested schemas, canonical values, background work, policy hooks, PTC mode, and UI cards. - [Capability layering](../practice/index.md) — split a replaceable capability into Service Definition, Service Provider, and Consumer packages. diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index a07bef588f..25538e42b7 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -48,5 +48,5 @@ pnpm dsh web --patch ./scratch-plugin/cordis.yml ## 下一步 - [插件配置](./config.zh.md) — 让问候语可配置。 -- [工具编写参考](../../../cookbook/adding-a-tool.zh.md) — 查阅嵌套 schema、规范值、后台工作、策略钩子、Code Mode 和 UI 卡片。 +- [工具编写参考](../../../cookbook/adding-a-tool.zh.md) — 查阅嵌套 schema、规范值、后台工作、策略钩子、PTC mode 和 UI 卡片。 - [能力分层](../practice/index.zh.md) — 将可替换能力拆分为 Service Definition、Service Provider 和 Consumer 三类包。 diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index 1726916a52..9ca6e303d4 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.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 docs/user/guide/schedule.md -schedule.md: 58cfda2e79f05129b367fad98c595a2c67a6e32e -schedule.zh.md: 75f85b1847c8e02cbd06d21f953ea6814a71ef40 +schedule.md: fdb4899925f34d6c58fa34cc6ee1e589b461c001 +schedule.zh.md: d5d134ed7ec53c61e7efdf5267f915fc26a427ff diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index 58cfda2e79..fdb4899925 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -10,6 +10,8 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml The current overlay supports reminders created with a positive whole-number `after_seconds`, an absolute `at` target, or a fixed-rate `every_seconds` interval of at least 300 seconds. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies delivery as `session-local`. +With this overlay enabled, a successfully opened Session with active reminders shows a read-only catalog in the conversation header. It lists the complete prompt, scheduled or overdue status, one-time or exact repeating cadence, browser-local target time, and relative time. The sidebar also places a non-interactive alarm after the title of grouped, flat, and search rows when their currently available projection value is non-empty. These surfaces never create, edit, delete, or acknowledge reminders, and a cold Session's cached alarm can be briefly missing or stale. + The browser attaches its IANA zone to each prompt. Time-context tells the model to interpret otherwise-unqualified dates and times in that request's browser zone. This assumption belongs to natural-language interpretation only: `schedule_create.at` must be either a strict RFC 3339 date-time with `Z` or a numeric offset, or `{ date, time, time_zone }` with an explicit `UTC` or IANA Area/Location zone. Schedule does not retain or infer a Session default zone. Daylight-saving gaps are rejected, overlaps choose the first instant, and successful records keep only the resulting UTC target. The original Session log owns each reminder. A live root Agent waits until it is fully idle, then queues a normal follow-up turn in that conversation. It never steers current work and adds no separate receipt or reminder card. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Reading cold history never activates it, and a fork does not inherit its parent's reminders. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index 75f85b1847..d5d134ed7e 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -10,6 +10,8 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml 当前 overlay 支持使用正整数 `after_seconds`、绝对时间 `at` 目标,或至少 300 秒的固定速率 `every_seconds` 间隔创建提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付标为 `session-local`。 +启用此 overlay 后,成功打开且存在活动提醒的 Session 会在对话 header 中显示只读目录。目录列出完整 prompt、等待中或已逾期状态、单次或精确重复周期、浏览器本地目标时间与相对时间。侧边栏还会在 grouped、flat 与 search 行当前可用的 projection 值非空时,于标题后显示不可交互的闹钟。这些界面不会创建、编辑、删除或确认提醒;cold Session 的缓存闹钟允许短暂漏显或残留。 + 浏览器会为每条提示词附加其 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该请求的浏览器时区。此假设仅用于自然语言解释:`schedule_create.at` 必须是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,或是带显式 `UTC` 或 IANA Area/Location 时区的 `{ date, time, time_zone }`。Schedule 不保留或推断 Session 默认时区。夏令时缺口会被拒绝,重叠时段选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。 每条提醒由原 Session 日志拥有。live 根 Agent 会等待到完全 idle,再在该对话中排入一个普通 follow-up 轮次。它绝不会中途引导当前工作,也不会添加独立回执或提醒卡片。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。 diff --git a/knip.json b/knip.json index b08fe22a25..a1c247f0d5 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,15 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native" ] }, + "packages/experimental/inspector": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/extensions/cordis-host-runner": { "entry": [ "tests/**/*.spec.ts" @@ -406,6 +415,11 @@ "tests/**/*.ts" ] }, + "packages/llm/llm": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/llm/llm-deepseek": { "entry": [ "tests/**/*.spec.ts", @@ -446,11 +460,6 @@ "tests/**/*.ts" ] }, - "packages/context/file-reference": { - "ignoreDependencies": [ - "zod" - ] - }, "packages/context/session-reference": { "ignoreDependencies": [ "zod" @@ -696,8 +705,7 @@ "@deepseek-ai/dsh-llm-deepseek", "@deepseek-ai/dsh-session-checkpoint-policy", "@deepseek-ai/dsh-session-persistence-jsonl", - "@deepseek-ai/dsh-skill-filesystem", - "@deepseek-ai/dsh-tool-subagent" + "@deepseek-ai/dsh-skill-filesystem" ] }, "packages/shell/tool-pwsh": { diff --git a/package.json b/package.json index bec66ae4c9..f5718565aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "license": "MIT", "private": true, "type": "module", @@ -112,6 +112,8 @@ "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", + "gen-tsconfig-paths": "tsx scripts/gen-tsconfig-paths.ts", + "verify-tsconfig-paths": "tsx scripts/gen-tsconfig-paths.ts --check", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", @@ -146,7 +148,8 @@ "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:code-mode": "node scripts/demo-code-mode.mjs", + "demo:ptc": "node scripts/demo-ptc.mjs", + "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e753147a72..af47b16740 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): -- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `runtime-diagnostics/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). +- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), sets `rootDir: src` and `outDir: lib/types`, references workspace dependencies plus `runtime-diagnostics/invariants`, and registers in one aggregate. Packages with distinct Host and Client compiler faces use `tsconfig.host.json` and `tsconfig.client.json` leaves plus a solution-only root; ordinary two-entry Client plugins do not split ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts). diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 7816391ff8..158a9ead5c 100644 --- a/packages/README.i18n.yaml +++ b/packages/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 packages/README.md -README.md: c3dcbd3032e3eadad21b640f3a614ef5a1a6c6e7 -README.zh.md: 80518ef0de7c2022db45aefa411f507870cf09aa +README.md: e1a729a6c80d8f8125e0d4c4b038dc631edd7a26 +README.zh.md: 754ad0fc44677afb243c3a32a0281f58ec3b3af2 diff --git a/packages/README.md b/packages/README.md index c3dcbd3032..e1a729a6c8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -40,7 +40,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tools | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, model-facing tools | -| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | +| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + PTC mode Consumer | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, discovery tools | | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index 80518ef0de..754ad0fc44 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -40,7 +40,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | | [`shell/`](shell/README.zh.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | | [`terminal/`](terminal/README.zh.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现、面向模型的工具 | -| [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | +| [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + PTC mode Consumer | | [`sandbox/`](sandbox/README.zh.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | | [`fs/`](fs/README.zh.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、发现工具 | | [`lsp/`](lsp/README.zh.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 1bd6af44c1..038be6f7a9 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index b93fd190e7..c47c67f406 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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 packages/api/README.md -README.md: aaea6ef47aabdc8baa63ef7a875de065bcae94b9 -README.zh.md: 8e87f97c413620ede4ee160384d00f18382cfdf7 +README.md: b4d8ddd84baa411a2675c95dda1701937e06fe6d +README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec diff --git a/packages/api/README.md b/packages/api/README.md index aaea6ef47a..b4d8ddd84b 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -32,19 +32,18 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | -Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Endpoints without a Remote definition fall through to the application's API Proxy. +Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. ----- ## Related documentation -Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions, and the carrier and fallback packages for how calls travel and how endpoints without Remote definitions are served. +Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions and Connection for the physical carrier. - [API Gateway reference](../../docs/api-gateway.md) — the current-state reference for the Typert API Gateway: programming model, generation pipeline, and runtime invocation. - [Typert subsystem reference](../../docs/subsystems/typert.md) — the public contracts shared by protocol, Gateway, and consumer assemblies. - [Connection](../client/connection/README.md) — the RPC carrier, `/api` trust fence, and response envelopes behind every Remote call. -- [API Proxy](../host/apiproxy/README.md) — the fallback for endpoints without Remote descriptors. ## Dev Note diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 8e87f97c41..5dff7a5921 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -32,19 +32,18 @@ kind: "package-group" | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。没有 Remote 定义的 endpoint 会回退到应用的 API Proxy。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- ## 相关文档 -先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,以及载体与回退包了解调用如何传输、没有 Remote 定义的 endpoint 如何被服务。 +先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,并通过 Connection 了解物理载体。 - [API Gateway 参考](../../docs/api-gateway.zh.md)——Typert API Gateway 的现状参考:编程模型、生成流水线与运行时调用。 - [Typert 子系统参考](../../docs/subsystems/typert.zh.md)——protocol、Gateway 与消费方装配共享的公共约定。 - [Connection](../client/connection/README.zh.md)——每次 Remote 调用背后的 RPC 载体、`/api` 信任围栏与响应封装。 -- [API Proxy](../host/apiproxy/README.zh.md)——没有 Remote 描述符的 endpoint 的回退路径。 ## 开发备注 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 9bc399b963..f1d957d143 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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 packages/api/gateway/README.md -README.md: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac -README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca +README.md: d10eed46d5534a2459995bd687942d799f009c7b +README.zh.md: 310b44cfe633758089771cda4040679c09373ec2 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 2e0cb32e4d..d10eed46d5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -28,13 +28,13 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. ## Client service: `ClientRemote` (ctx key: `remote`) @@ -45,7 +45,7 @@ Each unary call validates positional inputs, constructs the descriptor's exact n `ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the `ready` item and `host.describe` jointly establish a Connection generation. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. @@ -68,6 +68,7 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. +- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index f67e13f6b1..310b44cfe6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -28,13 +28,13 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验、每 Client 队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;`ready` 项与 `host.describe` 共同建立一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 @@ -68,6 +68,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 +- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index e74d19946f..d5601ab188 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -56,7 +56,9 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "peerDependencies": { diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index 341c1f3f4d..9552478162 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionGenerationSource, + ConnectionHostInfo, ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -118,7 +119,10 @@ export class ClientRemoteEvents { } /** Run one Connection generation over the forwarded-event logical stream. */ - private async pumpEvents(signal: AbortSignal, ready: () => void): Promise { + private async pumpEvents( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, + ): Promise { let clientId: RemoteEventClientId | undefined const failed = new AbortController() const generationSignal = AbortSignal.any([signal, failed.signal]) @@ -134,8 +138,9 @@ export class ClientRemoteEvents { try { for await (const value of source) { if (clientId === undefined) { - clientId = parseRemoteEventReady(value) - ready() + const opening = parseRemoteEventReady(value) + clientId = opening.clientId + ready(opening.host) continue } const frame = parseRemoteEventFrame(value) @@ -251,15 +256,21 @@ export class ClientRemoteEvents { } } -/** Validate and return the Client identity from one generation's opening item. */ -function parseRemoteEventReady(value: unknown): RemoteEventClientId { +/** Validate and return one generation's Client identity and Host facts. */ +function parseRemoteEventReady(value: unknown): { + readonly clientId: RemoteEventClientId + readonly host: ConnectionHostInfo +} { if (!isRemoteEventRecord(value) - || !hasExactRemoteEventKeys(value, ['type', 'clientId']) + || !hasExactRemoteEventKeys(value, ['type', 'clientId', 'host']) || value.type !== 'ready' - || !isRemoteEventClientId(value.clientId)) { + || !isRemoteEventClientId(value.clientId) + || !isRemoteEventRecord(value.host) + || !hasExactRemoteEventKeys(value.host, ['home']) + || typeof value.host.home !== 'string') { throw new TypeError('client api: forwarded Remote event stream did not begin with ready') } - return value.clientId + return { clientId: value.clientId, host: { home: value.host.home } } } /** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 799a371ef5..71f1f546ae 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -48,7 +48,7 @@ export class RemoteStream implements AsyncIterable> * @param options - domain stream opener, end classification, and diagnostics. */ constructor( - private readonly connection: Pick, + private readonly connection: Pick, private readonly options: RemoteStreamOptions, ) {} @@ -157,13 +157,13 @@ export class RemoteStream implements AsyncIterable> } async function waitForRemoteStreamRetry( - connection: Pick, + connection: Pick, error: RemoteStreamCarrierError, attempt: number, signal: AbortSignal, ): Promise { signal.throwIfAborted() - if (connection.hostDescription.getSnapshot() !== undefined) { + if (connection.generation.getSnapshot() !== undefined) { if (attempt === 1) return throw error } @@ -181,12 +181,12 @@ async function waitForRemoteStreamRetry( else reject(failure) } const inspect = (): void => { - if (connection.hostDescription.getSnapshot() !== undefined) finish() + if (connection.generation.getSnapshot() !== undefined) finish() } const aborted = (): void => { finish(new Error('Remote stream retry aborted', { cause: signal.reason })) } - const dispose = connection.hostDescription.subscribe(inspect) + const dispose = connection.generation.subscribe(inspect) subscription.dispose = dispose if (subscription.finished) dispose() signal.addEventListener('abort', aborted, { once: true }) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 86a71d834a..1d754b05a7 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import z from '@deepseek-ai/schemastery' import { remoteMethods, TypertLookupFailure, @@ -46,6 +48,7 @@ import { type RemoteEventCancellationFrame, type RemoteEventClientId, type RemoteEventEmitFrame, + type RemoteEventHostInfo, type RemoteEventId, type RemoteEventInvocationFrame, type RemoteEventReadyFrame, @@ -64,6 +67,7 @@ export type { TypertRemoteEventOutcome, TypertRemoteEventSource, } from './types.ts' +export type { RemoteEventHostInfo } from './stream-protocol.ts' interface GatewayErrorOptions { readonly cause?: unknown @@ -86,6 +90,7 @@ interface PreparedInvocation { interface RegisteredRemoteEventSource { readonly lifetime: AbortController readonly done: Promise + readonly host: RemoteEventHostInfo } interface RemoteEventClient { @@ -106,6 +111,17 @@ interface PendingRemoteEvent { type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000 + +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} + +interface ResolvedConfig extends Config { + readonly websocketHeartbeatIntervalMs: number +} /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -156,6 +172,10 @@ class RemoteInvocationCancelled extends Error { */ export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + static Config: z = z.object({ + websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) + .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { @@ -171,9 +191,11 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the Gateway against the active Typert registry. * @param ctx - owning Host Context with Typert registry access. + * @param config - validated Gateway transport configuration. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: Config) { super(ctx, 'typertGateway') + const resolved = config as ResolvedConfig ctx.on('internal/service', () => { this.srcClaims = undefined }) @@ -188,6 +210,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const mux = new RemoteStreamMuxServer( (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, ) webCtx.effect(() => { const route: WebUpgradeRoute = { @@ -213,9 +236,13 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise { + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise { if (this.remoteEvents !== undefined) { throw new Error('typert gateway: forwarded Remote event source is already registered') } @@ -227,7 +254,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.remoteEvents = undefined lifetime.abort(error) }) - const registration: RegisteredRemoteEventSource = { lifetime, done } + const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } } this.remoteEvents = registration return async () => { if (this.remoteEvents === registration) { @@ -397,7 +424,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.remoteEventClients.set(clientId, client) for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) try { - yield { ...REMOTE_EVENT_STREAM_READY, clientId } + yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) } finally { this.removeRemoteEventClient(client) diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 2c6e8ebf70..142598863e 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -23,10 +23,18 @@ export type RemoteEventClientId = Branded<'RemoteEventClientId'> /** Opaque correlation id for one pending Host-to-Client Remote Event. */ export type RemoteEventId = Branded<'RemoteEventId'> +/** Stable Host facts published with every established Client event generation. */ +export interface RemoteEventHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string +} + /** Opening item that binds later HTTP results to this active event stream. */ export interface RemoteEventReadyFrame { readonly type: 'ready' readonly clientId: RemoteEventClientId + /** Stable Host facts attached to this connection generation. */ + readonly host: RemoteEventHostInfo } /** Opaque Agent identity carried by one scoped Remote Event. */ diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 04b0df0427..28a3589542 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -23,14 +23,17 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() + private heartbeatTimer: NodeJS.Timeout | undefined /** * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. + * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly heartbeatIntervalMs: number, ) {} /** @@ -41,6 +44,7 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() this.connections.add(done) @@ -50,6 +54,8 @@ export class RemoteStreamMuxServer { /** Terminate all sockets and wait until every iterator has returned. */ async close(): Promise { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = undefined for (const socket of this.server.clients) socket.terminate() const closed = Promise.withResolvers() this.server.close((error) => { @@ -59,6 +65,17 @@ export class RemoteStreamMuxServer { await closed.promise await Promise.all(this.connections) } + + /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */ + private startHeartbeat(): void { + if (this.heartbeatTimer !== undefined) return + this.heartbeatTimer = setInterval(() => { + for (const socket of this.server.clients) { + if (socket.readyState === WebSocket.OPEN) socket.ping() + } + }, this.heartbeatIntervalMs) + this.heartbeatTimer.unref() + } } interface ActiveStream { diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index b41f35e905..9b4475cb9a 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { @@ -124,9 +125,13 @@ export interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index a278cc6acd..234251babb 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -5,23 +5,17 @@ import { RemoteStream, } from '../src/client/index.ts' -const DESCRIPTION = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION = { id: 1, host: { home: '/home/fixture' } } function hostSource(initiallyAvailable: boolean): { - connection: Pick + connection: Pick publish(available: boolean): void } { - let current = initiallyAvailable ? DESCRIPTION : undefined + let current = initiallyAvailable ? GENERATION : undefined const listeners = new Set<() => void>() return { connection: { - hostDescription: { + generation: { getSnapshot: () => current, subscribe: (listener) => { listeners.add(listener) @@ -30,7 +24,7 @@ function hostSource(initiallyAvailable: boolean): { }, }, publish: (available) => { - current = available ? DESCRIPTION : undefined + current = available ? GENERATION : undefined for (const listener of listeners) listener() }, } @@ -67,7 +61,7 @@ function scripted(generations: Generation[], opened?: () => void) { } function supervisor( - connection: Pick, + connection: Pick, generations: Generation[], carrierFailed?: (error: RemoteStreamCarrierError) => void, ): RemoteStream { @@ -122,8 +116,8 @@ describe('RemoteStream', () => { let listener: (() => void) | undefined const subscribed = Promise.withResolvers() const connection = { - hostDescription: { - getSnapshot: () => available ? DESCRIPTION : undefined, + generation: { + getSnapshot: () => available ? GENERATION : undefined, subscribe: (value: () => void) => { listener = value subscribed.resolve(undefined) @@ -177,8 +171,8 @@ describe('RemoteStream', () => { let reads = 0 let disposed = 0 const connection = { - hostDescription: { - getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION, + generation: { + getSnapshot: () => reads++ === 0 ? undefined : GENERATION, subscribe: (listener: () => void) => { listener() return () => { disposed++ } @@ -261,7 +255,7 @@ describe('RemoteStream', () => { const holder: { stream?: RemoteStream } = {} let subscriptions = 0 const connection = { - hostDescription: { + generation: { getSnapshot: () => undefined, subscribe: () => { subscriptions++ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index d7289783d3..c769192a4b 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -5,6 +5,7 @@ import WebSocket, { type RawData } from 'ws' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { bindTypertRemote, Remote, @@ -17,6 +18,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, + type Config as GatewayConfig, type TypertRemoteEventDispatch, type TypertRemoteEventInvocation, type TypertRemoteEventOutcome, @@ -34,6 +36,7 @@ vi.mock('node:crypto', async (importOriginal) => { const randomUuid = vi.mocked(randomUUID) const browserCookies = new WeakMap() +const REMOTE_HOST = { home: '/home/fixture' } as const type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId @@ -211,6 +214,15 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { + it('validates the WebSocket heartbeat timer range', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { + expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() + } + }) + it('opens decoded carrier payloads through the in-process wire adapter', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.wireStream.open( @@ -280,6 +292,19 @@ describe('Typert Remote streams', () => { })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) }) + it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => { + const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + const ping = once(socket, 'ping') + await once(socket, 'open') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + + socket.close() + await once(socket, 'close') + }) + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -362,8 +387,8 @@ describe('Typert Remote streams', () => { } })() } - const unregister = ctx.typertGateway.registerRemoteEvents(source) - expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) + expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) }) .toThrow('forwarded Remote event source is already registered') const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -378,7 +403,7 @@ describe('Typert Remote streams', () => { const eventFrames = frames.filter(frame => frame.streamId === 'events') expect(eventFrames).toHaveLength(1) expect(eventFrames[0]).toMatchObject({ - type: 'item', streamId: 'events', value: { type: 'ready' }, + type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST }, }) expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') }) @@ -387,7 +412,7 @@ describe('Typert Remote streams', () => { const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2) expect(eventFrames).toHaveLength(2) expect(eventFrames[0]).toMatchObject({ - type: 'item', streamId: 'events', value: { type: 'ready' }, + type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST }, }) expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') expect(eventFrames[1]).toEqual({ @@ -405,9 +430,9 @@ describe('Typert Remote streams', () => { expect(frames).toContainEqual({ type: 'end', streamId: 'events' }) }) - const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source) + const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) await unregister() - expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) }) .toThrow('forwarded Remote event source is already registered') await unregisterReplacement() socket.close() @@ -422,7 +447,7 @@ describe('Typert Remote streams', () => { await publish.promise yield pending.dispatch })() - const unregister = ctx.typertGateway.registerRemoteEvents(source) + const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) const rejected = expect(pending.outcome).rejects.toThrow( 'forwarded Remote event source was removed', ) @@ -455,7 +480,7 @@ describe('Typert Remote streams', () => { else signal.addEventListener('abort', () => { resolve() }, { once: true }) }) throw new Error('fixture source rejected during removal') - })()) + })(), REMOTE_HOST) const client = await openEventClient(ctx, 'events-removal') await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) @@ -472,7 +497,7 @@ describe('Typert Remote streams', () => { it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) for (const event of [42, ''] as const) { const invalidName = pendingInvocation(ctx) @@ -555,7 +580,7 @@ describe('Typert Remote streams', () => { return (async function* () { yield frame as unknown as TypertRemoteEventDispatch })() - }) + }, REMOTE_HOST) await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) const reason: unknown = sourceSignal?.reason if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error') @@ -567,7 +592,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event id before publishing the second waterfall', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -602,7 +627,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) @@ -621,7 +646,7 @@ describe('Typert Remote streams', () => { it('fans one scoped waterfall out and accepts the first Client result', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -675,7 +700,7 @@ describe('Typert Remote streams', () => { it('rejects the Host waterfall with the first Client listener rejection', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -715,7 +740,7 @@ describe('Typert Remote streams', () => { it('delegates to the Host only after every active Client returns next', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -748,7 +773,7 @@ describe('Typert Remote streams', () => { it('delivers a pending waterfall to the first Client that connects', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -781,7 +806,7 @@ describe('Typert Remote streams', () => { it('replays a pending event id to a replacement Client generation', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -815,7 +840,7 @@ describe('Typert Remote streams', () => { it('cancels pending deliveries when the Host signal or Context ends', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const signalAgent = ctx.extend() const contextFiber = ctx.plugin(() => {}) await contextFiber @@ -905,7 +930,7 @@ describe('Typert Remote streams', () => { const unregister = ctx.typertGateway.registerRemoteEvents(() => { sourceCalls += 1 return (async function *(): AsyncIterable {})() - }) + }, REMOTE_HOST) const invalidPayloads: readonly unknown[] = [ null, [], @@ -964,7 +989,10 @@ describe('Typert Remote streams', () => { }) }) -async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { +async function setup( + transport: boolean, + gatewayConfig: GatewayConfig = {}, +): Promise<{ readonly ctx: Context; readonly service: FeedService }> { const ctx = new Context() roots.push(ctx) if (transport) { @@ -972,7 +1000,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) - await ctx.plugin(TypertGatewayService) + await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) } diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 92eeecb5e0..3122fbffd5 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -483,7 +483,7 @@ class RemoteEventCarrier { const abort = (): void => { connection.wake?.() } signal.addEventListener('abort', abort, { once: true }) try { - yield { type: 'ready', clientId } + yield { type: 'ready', clientId, host: { home: '/home/fixture' } } while (!signal.aborted) { while (connection.items.length > 0) { const item = connection.items.shift() as EventStreamItem @@ -1769,7 +1769,7 @@ describe('Client Typert API', () => { socket.receive({ type: 'item', streamId: opened.streamId, - value: { type: 'ready', clientId: 'browser-client' }, + value: { type: 'ready', clientId: 'browser-client', host: { home: '/home/browser' } }, }) await run.ready socket.receive({ @@ -1809,7 +1809,7 @@ describe('Client Typert API', () => { }) }) - it('publishes the Fixture Host description after Remote events report ready', async () => { + it('publishes the Fixture Host facts after Remote events report ready', async () => { const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') Object.defineProperty(globalThis, 'location', { configurable: true, @@ -1824,7 +1824,7 @@ describe('Client Typert API', () => { if (connection === undefined) throw new Error('fixture Connection service is unavailable') await vi.waitFor(() => { - expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') + expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture') }) } finally { await ctx.fiber.dispose() @@ -1841,6 +1841,10 @@ describe('Client Typert API', () => { { type: 'ready' }, { type: 'ready', clientId: '' }, { type: 'ready', clientId: 'client', extra: true }, + { type: 'ready', clientId: 'client', host: null }, + { type: 'ready', clientId: 'client', host: {} }, + { type: 'ready', clientId: 'client', host: { home: 1 } }, + { type: 'ready', clientId: 'client', host: { home: '/home', extra: true } }, { type: 'emit', event: 'fixture/changed', args: ['too early'] }, ])('rejects malformed forwarded-event readiness item %#', async (opening) => { const open: NonNullable = () => (async function *() { diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index c0455bba10..ab1ee9d6b9 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1084,11 +1084,14 @@ describe('TypertGatewayService', () => { if (signal.aborted) resolve() else signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - })()) + })(), { home: '/home/fixture' }) const carrier = new AbortController() const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) const opening = await events.next() - expect(opening).toMatchObject({ done: false, value: { type: 'ready' } }) + expect(opening).toMatchObject({ + done: false, + value: { type: 'ready', host: { home: '/home/fixture' } }, + }) if (opening.done) throw new Error('Remote event stream ended before ready') const clientId: unknown = Reflect.get(opening.value as object, 'clientId') if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id') diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts index 0d134f3d99..e5e02b2d28 100644 --- a/packages/api/gateway/tests/journal-stream.client.spec.ts +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -42,10 +42,8 @@ interface Generation { type PageSource = Page | Promise | ((signal: AbortSignal) => Promise) const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 9746943b63..2cc4c99f8c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -25,6 +25,31 @@ afterEach(async () => { }) describe('Remote stream mux server carrier lifecycle', () => { + it('sends WebSocket Ping control frames without application messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const messages = vi.fn() + client.on('message', messages) + + const ping = once(client, 'ping') + const pong = once(serverSocket, 'pong') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + expect((await pong)[0]).toEqual(Buffer.alloc(0)) + expect(messages).not.toHaveBeenCalled() + + const closingPing = vi.spyOn(serverSocket, 'ping') + client.pause() + serverSocket.close() + expect(serverSocket.readyState).toBe(WebSocket.CLOSING) + await new Promise((resolve) => { setTimeout(resolve, 25) }) + expect(closingPing).not.toHaveBeenCalled() + + const closed = once(client, 'close') + client.resume() + await closed + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -168,8 +193,8 @@ const mapFailure: RemoteStreamFailureMapper = error => ({ details: {}, }) -async function startMux(open: RemoteStreamOpener): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure) +async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) const http = createServer() http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) await new Promise((resolve, reject) => { diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 46d1b3a88d..54d5f964f3 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -19,6 +19,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../runtime-diagnostics/invariants" }, @@ -30,6 +33,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c1158e8aa7..43991614b9 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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 packages/api/remotes/README.md -README.md: 50361d1a6ae2f0b3b53a5bd928429414ab12bbce -README.zh.md: 5545198cce2e43513af73adfc9d4277831adae92 +README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 +README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 50361d1a6a..7f855bd1cd 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -40,18 +40,18 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. -The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active. Withdrawing the registration aborts active streams; API Proxy does not participate in event forwarding or Connection generation. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. ## Build boundary -An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. +Most repository packages belong to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. This package splits because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift. That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional. -The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. +The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; split only when the two source sets require different compiler faces. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5545198cce..edad8d735d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -40,18 +40,18 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 -Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项能证明增量投递已就绪。撤回注册会中止活动 stream;API Proxy 不参与事件转发或 Connection generation。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 ## 构建边界 -仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 +仓库中的多数包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。本包需要拆分,因为 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts` 与 `src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。 这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。 -包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 +包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;只有两组源码需要不同 compiler face 时才拆分。 ## 模型体验 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index aef2bcc642..8a271fe29a 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 8638c54b9f..d449efe704 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -5,8 +5,8 @@ import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' -import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' @@ -20,8 +20,8 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven export type {} from '@deepseek-ai/dsh-agent-presets/remote' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' -export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' @@ -54,11 +54,10 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - DiscoveredModelView, IApiClient, - MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + ConnectionHandle, ConnectionSinks, ContentBlock, + MessageId, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, - SkillEntry, StreamChunk, + StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -111,6 +110,11 @@ export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' export type { SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, } from '@deepseek-ai/dsh-settings/types' +// Provider registry and discovery vocabulary for the llm namespace. +export type { + LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryError, + LlmModelDiscoveryRequest, LlmProviderInfo, +} from '@deepseek-ai/dsh-llm/types' // Reference-discovery result vocabulary for the fileReferences and // sessionReferenceResolver namespaces. export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' @@ -123,6 +127,7 @@ export type ClientFailure = | import('@deepseek-ai/dsh-api-session-controller/types').SessionError | import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError | import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError + | import('@deepseek-ai/dsh-llm/types').LlmModelDiscoveryError | import('@deepseek-ai/dsh-subagent/client').SubagentControlError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError @@ -150,8 +155,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, dynamicRemote, - fileReferencesRemote, + agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4d0256e162..e775d3a5ae 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,5 +1,6 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ +import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' import type { TypertRemoteEventDispatch, @@ -35,7 +36,7 @@ export const inject = ['typertGateway'] /** Host plugin body registering this application's selected Cordis event source. */ export function apply(ctx: Context): void { ctx.effect( - () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)), + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }), 'api-remotes: forwarded Cordis event source', ) } diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index eefe64e664..1d2e8c235d 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + RemoteEventHostInfo, TypertRemoteEventInvocation, TypertRemoteEventSource, } from '@deepseek-ai/dsh-api-gateway' @@ -10,8 +11,12 @@ import { apply, inject } from '../src/index.ts' interface GatewayProbe { source: TypertRemoteEventSource | undefined + host: RemoteEventHostInfo | undefined removals: number - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise } async function setup(): Promise<{ @@ -22,12 +27,15 @@ async function setup(): Promise<{ const ctx = new Context() const gateway: GatewayProbe = { source: undefined, + host: undefined, removals: 0, - registerRemoteEvents(source) { + registerRemoteEvents(source, host) { gateway.source = source + gateway.host = host return async () => { if (gateway.source !== source) return gateway.source = undefined + gateway.host = undefined gateway.removals += 1 } }, @@ -71,6 +79,14 @@ function invocationOf(value: unknown): TypertRemoteEventInvocation { } describe('Remote event Host source', () => { + it('registers the Host home used by Client connection generations', async () => { + const { gateway, fiber } = await setup() + expect(gateway.host?.home).toBeTypeOf('string') + expect(gateway.host?.home.length).toBeGreaterThan(0) + await fiber.dispose() + expect(gateway.host).toBeUndefined() + }) + it('gives each Client stream an independent allowlisted event queue', async () => { const { ctx, gateway, fiber } = await setup() const firstAbort = new AbortController() diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 585262a1fe..fc66cdfceb 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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 packages/api/session-controller/README.md -README.md: d3c139f6c093fb77bd0d387793ae6823e780c599 -README.zh.md: 10f062e49e2241aa04fa3636122f08738d3c8ce1 +README.md: 689635b83bce71e3aecfedf371685a24bede55ab +README.zh.md: 68da7db60b38aca1b72c991005438ced45b348ea diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index d3c139f6c0..689635b83b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state. Use it through API Gateway when a Client needs these Session operations. +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and the adapter for Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. ## Table of Contents @@ -25,9 +25,11 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For projections, a Session omitted from that process-local roster invalidates its prior authoritative Client rows; the Client may immediately show its latest retained list-cache hint as tentative until a follow opening or later frame supplies authoritative state. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. + +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone. ----- @@ -37,6 +39,7 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` | Field | Default | Meaning | |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | +| `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. @@ -57,6 +60,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. +- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 10f062e49e..68da7db60b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。当 Client 需要这些 Session 操作时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现,以及面向 Agent 的文件引用 adapter。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 ## 目录 @@ -25,9 +25,11 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。对于 projection,若某个 Session 未出现在该进程本地 roster 中,它在 Client 上一代的权威 row 就会失效;在 follow opening 或后续 frame 提供权威状态前,Client 可以立即把最近保留的 list-cache hint 作为暂定值展示。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 + +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。 ----- @@ -37,6 +39,7 @@ Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session | 字段 | 默认值 | 含义 | |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | +| `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 @@ -57,6 +60,7 @@ Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 +- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index dabfd8fd6a..4aa30bd9e4 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -85,15 +85,18 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -116,9 +119,11 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -126,6 +131,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 6ac4182bb9..9b8ed3f7ec 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -12,9 +12,37 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction } from '../../types.ts' +import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' -import type { SessionSnapshot } from './snapshot.ts' +import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' + +/** + * Why a local submission echo left the snapshot: `observed` when its durable + * `user/message` event or host queue occurrence arrived (with the admitted + * image references in prompt order), `failed` when the prompt was rejected, + * threw, or was aborted before acceptance. + */ +export type PendingSubmissionRetirement = + | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { readonly reason: 'failed' } + +/** Input registering one local submission echo ahead of its prompt call. */ +export interface BeginSubmissionInput { + /** Prompt text exactly as the upcoming prompt will send it. */ + readonly text: string + /** Ordered image previews matching the upcoming prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] + /** Settlement callback fired exactly once when the echo retires. */ + readonly onRetire?: (retirement: PendingSubmissionRetirement) => void +} + +/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */ +export interface SubmissionHandle { + /** The prompt RPC identity; pass it to {@link ISession.prompt}. */ + readonly requestId: SessionRequestId + /** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */ + abandon(): void +} /** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ export interface ProjectionsFace { @@ -33,16 +61,29 @@ export interface ISession { readonly sessionId: SessionId /** Host-computed projection values by key (the useProjection seat). */ readonly projections: ProjectionsFace + /** + * Register one local submission echo in `snapshot.pendingSubmissions`, + * synchronously, before the caller serializes and sends the prompt. The + * echo retires when a durable `user/message` event or queue occurrence + * carrying the returned identity arrives, or when the identified prompt + * call fails. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle /** * Send a prompt into the session. * @param content - text plus browser-owned temporary image uploads. * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> /** * Resolve one durable image referenced by this session. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 3f5f4ebd1c..8aacc0d5d6 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -3,6 +3,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { SessionRequestId } from '../../types.ts' import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ @@ -10,11 +11,42 @@ export interface QueuedMessage { readonly id: MessageId readonly messageId: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */ + readonly rpcId?: SessionRequestId readonly content: readonly ContentBlock[] readonly preview: string readonly text: string | null } +/** One image displayed by a local submission echo before durable admission. */ +export interface PendingSubmissionImage { + /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ + readonly previewUrl: string + /** Browser file name, when the file had one. */ + readonly name?: string + /** Intrinsic pixel width, when the submitter has probed it. */ + readonly width?: number + /** Intrinsic pixel height, when the submitter has probed it. */ + readonly height?: number +} + +/** + * One local prompt-submission echo: inserted synchronously when a submission + * begins, so the conversation can show the message before serialization, + * transport, and durable admission complete. Client-memory only — reload and + * reconnect rebuild the conversation from durable events alone. + */ +export interface PendingSubmission { + /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ + readonly requestId: SessionRequestId + /** Client wall-clock ms when the submission began. */ + readonly time: number + /** Prompt text exactly as it will be sent (one text block). */ + readonly text: string + /** Ordered image previews matching the prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] +} + /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' @@ -28,6 +60,8 @@ export interface PromptError { export interface SessionSnapshot { readonly sessionId: SessionId readonly queue: readonly QueuedMessage[] + /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ + readonly pendingSubmissions: readonly PendingSubmission[] readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 10a10261bf..04b7cbb553 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -40,7 +40,14 @@ export type { SessionProjectionMap, UseProjection, } from './sessions/projection-store.ts' -export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { + BeginSubmissionInput, + ISession, + PendingSubmissionRetirement, + ProjectionsFace, + SessionFace, + SubmissionHandle, +} from './contract/session.ts' export type { ISessions } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { @@ -53,6 +60,8 @@ export type { } from './contract/events.ts' export type { OpenState, + PendingSubmission, + PendingSubmissionImage, PromptError, QueuedMessage, SessionSnapshot, @@ -102,7 +111,7 @@ export function apply(ctx: Context): void { }) control.start() ctx.on('connection/reset', () => { sessions.handleConnected() }) - if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() + if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected() ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), resolve: sessionId => sessions.resolveAgentScope(sessionId), diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index e0b2943ad1..9ad6d88f96 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -395,7 +395,7 @@ export class SessionManager { }) } } catch (error: unknown) { - const folded = transportResult(error) as Extract, { ok: false }> + const folded = transportResult(error) this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, @@ -405,7 +405,7 @@ export class SessionManager { ?? previous?.parentAvailable, ), state: 'error', - error: folded.error, + error: folded.ok ? null : folded.error, }) } finally { this.catalogInflight.delete(parentSessionId) @@ -488,9 +488,18 @@ export class SessionManager { session.handleBlank(s.blank) session.handleRunning(s.running) } - for (const summary of this.summaries) { - const projections = summary.projections - if (projections !== undefined) this.projectionStore(summary.sessionId).prewarm(projections) + // Seed each row's projection baseline into the per-session value + // store (cold titles surface without opening the session). Per-key + // apply, not seed(): the list block is a partial baseline — the + // cold cache serves only version-matching keys — so an absent key + // must not clear; higher-seq-wins still keeps a stale list block + // from overwriting a newer push frame or tail baseline. + for (const s of result.value.items) { + const block = s.projections + if (block === undefined) continue + const store = this.projectionStore(s.sessionId) + const values = block.values as Record + for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) } } else { this.listState = 'error' @@ -691,15 +700,10 @@ export class SessionManager { if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) } - const projected = new Set(Object.keys(baseline.projections)) - const summaries = new Map(this.summaries.map(summary => [summary.sessionId, summary])) - for (const [sessionId, store] of this.projectionStores) { - if (!projected.has(sessionId)) { - store.replaceControlOmission(summaries.get(sessionId)?.projections) - } - } for (const [sessionId, block] of Object.entries(baseline.projections)) { - this.projectionStore(sessionId as SessionId).replaceControlBaseline(block) + const store = this.projectionStore(sessionId as SessionId) + store.truncate(block.asOfSeq) + store.seed(block) } for (const [sessionId, session] of this.sessions) { session.replaceControl(this.queues.get(sessionId) ?? []) @@ -715,7 +719,12 @@ export class SessionManager { this.mergeSummary(summary) this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) const projections = summary.projections - if (projections !== undefined) this.projectionStore(summary.sessionId).prewarm(projections) + if (projections !== undefined) { + const store = this.projectionStore(summary.sessionId) + for (const [key, value] of Object.entries(projections.values)) { + store.apply(key, value, projections.asOfSeq) + } + } if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { this.markCatalogParentExpandable(summary.parentSessionId) } @@ -973,12 +982,9 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ? { parentSessionId: mutation.summary.parentSessionId } : {}), ...(existing.origin === undefined && mutation.summary.origin !== undefined ? { origin: mutation.summary.origin } : {}), - ...(mutation.summary.projections === undefined - ? {} : { projections: mutation.summary.projections }), } if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId && filled.origin === existing.origin && filled.blank === existing.blank - && filled.projections === existing.projections ) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index bac640cf14..fd0ad1b5d2 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -2,9 +2,9 @@ * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): * the host is the only computation site; the client holds finished - * whole values per key. The store distinguishes tentative list hints from - * authoritative frames and complete baselines, and owns their precedence - * across opening and reconnect lifecycles. No client-side domain folding + * whole values per key — `key → { value, seq }` — seeded by a follow opening + * baseline and updated by Session Controller `projection` frames, + * under the single rule **higher seq wins**. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). */ @@ -51,18 +51,10 @@ export interface ProjectionsBaseline { values: Readonly> } -/** Opaque marker for one exact Session opening lifecycle. */ -export interface ProjectionOpeningToken { - /** Store revision when the opening began. */ - readonly revision: number -} - -/** One key's row with its authority and arrival revision. */ +/** One key's row: the latest finished value and the seq it is consistent with. */ interface Row { value: unknown seq: number - provenance: 'tentative' | 'authoritative' - revision: number } /** Per-key notification channel: the bare face plus its batching notifier. */ @@ -72,22 +64,19 @@ interface Channel { } /** - * One session's projection values. Framework semantics are uniform across - * source: list hints are tentative, frames are authoritative per-key updates, - * and opening/control baselines are complete authoritative cuts. A key the - * store has never seen reads `undefined` (capability absent). Faces are - * identity-stable per key (create-on-demand, cached) so the React side binds - * each exactly once; the store-level channel (`subscribeAny`) serves coarse - * consumers. + * One session's projection values. Framework semantics, uniform across every + * key: a baseline seeds rows at its cut, a push frame updates one row, and in + * both paths a lower-or-equal seq loses — a replayed frame cannot regress a + * value, a stale baseline cannot overwrite a newer frame. A key the store has + * never seen reads `undefined` (capability absent). Faces are identity-stable + * per key (create-on-demand, cached) so the React side binds each exactly + * once; the store-level channel (`subscribeAny`) serves coarse consumers (the + * manager's list projection reads the `title` key). */ export class ProjectionValueStore { private readonly rows = new Map() private readonly channels = new Map() private valuesCache: Readonly> | undefined - private revision = 0 - private activeOpening: ProjectionOpeningToken | undefined - private latestControlBaseline: { readonly revision: number; readonly asOfSeq: number } | undefined - private completeBaselineInstalled = false /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */ private readonly anyNotifier = new Notifier(() => {}) @@ -136,122 +125,52 @@ export class ProjectionValueStore { } /** - * Apply the latest partial cache-backed hint while no complete authoritative - * cut exists. Arrival order, not the cached watermark, orders tentative rows: - * crash repair may legitimately lower the durable sequence. - * @param hint - partial projection values from the Session list cache. - */ - prewarm(hint: ProjectionsBaseline): void { - if (this.completeBaselineInstalled) return - const values = hint.values as Record - for (const key of Object.keys(values)) { - const previous = this.rows.get(key) - if (previous?.provenance === 'authoritative') continue - this.installRow(key, { - value: values[key], - seq: hint.asOfSeq, - provenance: 'tentative', - revision: ++this.revision, - }) - } - } - - /** - * Apply one authoritative finished value from the Session control stream. - * The first frame replaces a tentative row regardless of sequence; later - * authoritative frames use strict higher-sequence ordering. + * Apply one finished value from the Session control stream. * @param key - projection key. * @param value - whole value computed by the host unit. * @param seq - the unit's watermark at emission. */ apply(key: string, value: unknown, seq: number): void { const row = this.rows.get(key) - if (row?.provenance === 'authoritative' && seq <= row.seq) return - this.rows.set(key, { - value, - seq, - provenance: 'authoritative', - revision: ++this.revision, - }) + if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop + this.rows.set(key, { value, seq }) this.changed(key) } /** - * Start one exact opening. The token lets completion distinguish state that - * existed before the request from authoritative frames arriving afterward. - * @returns an opaque token owned by the caller's opening lifecycle. + * Seed from a history tail page's projections block: every carried key + * lands under the same seq rule as frames; a key the block omits is + * capability-absent as of the cut — its row clears unless a newer frame + * already superseded the cut (a stale baseline can neither overwrite nor + * clear newer values). + * @param baseline - the response's projections block. */ - beginOpening(): ProjectionOpeningToken { - const token = Object.freeze({ revision: this.revision }) - this.activeOpening = token - return token - } - - /** - * Complete an exact opening. A control baseline received after the token - * wins at an equal or newer cut. Otherwise the opening replaces all prior - * state and retains only authoritative post-token frames newer than its cut. - * @param token - token returned by {@link beginOpening}. - * @param baseline - complete projection values at the opening cut. - */ - completeOpening(token: ProjectionOpeningToken, baseline: ProjectionsBaseline): void { - if (this.activeOpening !== token) return - this.activeOpening = undefined - const control = this.latestControlBaseline - if (control !== undefined - && control.revision > token.revision - && control.asOfSeq >= baseline.asOfSeq) { - return + seed(baseline: ProjectionsBaseline): void { + // Erased walk: the framework crosses the open key space; per-key typing + // is re-established at the consumer (useProjection's map lookup). + const values = baseline.values as Record + for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq) + for (const [key, row] of this.rows) { + if (Object.hasOwn(values, key)) continue + if (row.seq > baseline.asOfSeq) continue + this.rows.delete(key) + this.changed(key) } - - const retained = [...this.rows].filter(([, row]) => - row.provenance === 'authoritative' - && row.revision > token.revision - && row.seq > baseline.asOfSeq) - this.completeBaselineInstalled = true - this.replaceRows(baseline, ++this.revision, 'authoritative') - for (const [key, row] of retained) this.installRow(key, row) } /** - * End one failed or superseded opening without changing the already visible - * values. Later openings receive a fresh revision boundary. - * @param token - token returned by {@link beginOpening}. + * Drop rows beyond a replacement control baseline. Such rows describe + * process state the Host lost before persisting it and would otherwise + * outrank recomputed lower-seq values forever. The caller seeds the new + * baseline immediately afterward. + * @param lastSeq - highest durable sequence reflected by the baseline. */ - cancelOpening(token: ProjectionOpeningToken): void { - if (this.activeOpening === token) this.activeOpening = undefined - } - - /** - * Replace the previous control-stream generation exactly, including rows at - * the same sequence. Frames arriving afterward again use higher-seq-wins. - * @param baseline - complete projections for the new control generation. - */ - replaceControlBaseline(baseline: ProjectionsBaseline): void { - const revision = ++this.revision - this.completeBaselineInstalled = true - this.latestControlBaseline = { revision, asOfSeq: baseline.asOfSeq } - this.replaceRows(baseline, revision, 'authoritative') - } - - /** - * Replace state for a Session omitted from a new control generation. The - * Host cut invalidates prior authoritative rows, while the latest retained - * list block may immediately repopulate tentative sidebar values. - * @param hint - latest partial list-cache block retained for the Session. - */ - replaceControlOmission(hint?: ProjectionsBaseline): void { - const revision = ++this.revision - this.completeBaselineInstalled = false - this.latestControlBaseline = undefined - if (hint === undefined) { - for (const key of this.rows.keys()) { - this.rows.delete(key) - this.changed(key) - } - return + truncate(lastSeq: number): void { + for (const [key, row] of this.rows) { + if (row.seq <= lastSeq) continue + this.rows.delete(key) + this.changed(key) } - this.replaceRows(hint, revision, 'tentative') } private changed(key: string): void { @@ -260,36 +179,6 @@ export class ProjectionValueStore { this.anyNotifier.markDirty() } - /** Replace every row with one baseline at the supplied authority. */ - private replaceRows( - baseline: ProjectionsBaseline, - revision: number, - provenance: Row['provenance'], - ): void { - const values = baseline.values as Record - const keys = new Set([...this.rows.keys(), ...Object.keys(values)]) - for (const key of keys) { - if (!Object.hasOwn(values, key)) { - this.rows.delete(key) - this.changed(key) - continue - } - this.installRow(key, { - value: values[key], - seq: baseline.asOfSeq, - provenance, - revision, - }) - } - } - - /** Install one row while notifying only when its observable value changes. */ - private installRow(key: string, row: Row): void { - const previous = this.rows.get(key) - this.rows.set(key, row) - if (previous === undefined || !Object.is(previous.value, row.value)) this.changed(key) - } - private channel(key: string): Channel { let channel = this.channels.get(key) if (channel === undefined) { diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 209349af2c..2a9f274b28 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -43,6 +43,7 @@ export class SessionQueueMirror { id: item.id, messageId: item.message.id, placement: item.placement, + ...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }), content, preview: previewOf(content), text: textOf(content), diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index e760d204dd..815cf89c40 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -22,9 +22,11 @@ import type { } from '../../types.ts' import type { ClientFailure, ClientResult } from '../contract/result.ts' import { transportResult } from '../contract/result.ts' -import type { SessionFace } from '../contract/session.ts' import type { - OpenState, PromptError, SessionSnapshot, + BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, +} from '../contract/session.ts' +import type { + OpenState, PendingSubmission, PromptError, SessionSnapshot, } from '../contract/snapshot.ts' import { MutableSessionEventSource } from '../contract/events.ts' import type { @@ -34,7 +36,7 @@ import { Notifier } from './notifier.ts' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' -import type { ProjectionOpeningToken, ProjectionsBaseline } from './projection-store.ts' +import type { ProjectionsBaseline } from './projection-store.ts' import { resolvedClientTimeZone } from '../time-zone.ts' import { SessionQueueMirror } from './queue-mirror.ts' @@ -80,8 +82,6 @@ export class Session implements SessionFace { /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale * passes drop all writes once the generation moves on. */ private openGeneration = 0 - /** Store-owned reconciliation token for the current exact opening. */ - private projectionOpening: ProjectionOpeningToken | undefined private loadingOlder = false /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private readonly queueMirror = new SessionQueueMirror() @@ -101,15 +101,23 @@ export class Session implements SessionFace { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null + /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ + private pendingSubmissions: readonly PendingSubmission[] = [] + /** Per-echo settlement state; `retiring` latches the first observation so a + * queue frame and its durable event cannot both retire one echo. */ + private readonly submissionSettlements = new Map void) | undefined + retiring: boolean + }>() /** Owns the addressed page/follow lifecycle while this Session is open. */ private events: SessionEventStream | undefined /** * Per-session projection value store (push model; see the session-projection * subsystem page, docs/subsystems/session-projection.md): finished whole - * values computed on the Host. The store owns precedence among tentative - * list hints, authoritative frames, complete control baselines, and exact - * opening baselines. Keys are read via `projections.faceOf(key)` + * values computed on the Host, seeded by the tail page's + * projections block and updated by Session Controller control frames under the + * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` * (the useProjection resolution face); the conversation snapshot never * carries projection values, and no client-side domain folding exists. * Manager-owned when constructed through SessionManager (frames route and @@ -170,16 +178,42 @@ export class Session implements SessionFace { // ---- Operations ---- + /** + * Register one local submission echo (see the ISession declaration). + * Synchronous through markDirty: the echo is in the very next snapshot, so + * the conversation can paint it before the caller starts serializing. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle { + const requestId = randomUUID() as SessionRequestId + this.pendingSubmissions = [...this.pendingSubmissions, { + requestId, + time: Date.now(), + text: input.text, + images: input.images, + }] + this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) + // The blank → engaging edge flips here, ahead of prompt(): the composer + // docks and the echo renders on the click's own frame. + this.promptAttempted = true + this.notifier.markDirty() + return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } } + } + /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns the prompt result (also mirrored into promptError on failure). */ async prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> { this.promptError = null this.lastAgentError = null @@ -194,7 +228,7 @@ export class Session implements SessionFace { if (this.address === undefined) { const clientTimeZone = resolvedClientTimeZone() result = toSessionResult(await this.remote.session.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, sessionId: this.sessionId, mode, content, @@ -237,6 +271,7 @@ export class Session implements SessionFace { result = transportResult(error) } if (!result.ok) { + if (requestId !== undefined) this.retireFailedSubmission(requestId) this.promptError = { op: 'send', error: result.error } this.notifier.markDirty() return result @@ -343,9 +378,7 @@ export class Session implements SessionFace { async rename(title: string): Promise> { try { const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title })) - if (result.ok) { - this.projections.apply('title', result.value.title, result.value.seq) - } + if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) return result } catch (error) { return transportResult(error) @@ -369,7 +402,6 @@ export class Session implements SessionFace { open(): Promise { if (this.openState === 'open') return Promise.resolve() if (this.openPromise !== null) return this.openPromise - this.beginProjectionOpening() const promise = this.doOpen(this.openGeneration).finally(() => { // Identity-guarded: a superseded open must not null out the promise resync just started. if (this.openPromise === promise) this.openPromise = null @@ -403,8 +435,6 @@ export class Session implements SessionFace { async resync(): Promise { 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.cancelProjectionOpening() - this.beginProjectionOpening() const events = this.events this.events = undefined await events?.dispose() @@ -444,6 +474,7 @@ export class Session implements SessionFace { */ replaceControl(queue: readonly SessionQueuedItem[]): void { this.queueMirror.replace(queue) + this.observeSubmissionQueue(queue) this.notifier.markDirty() } @@ -453,6 +484,7 @@ export class Session implements SessionFace { */ handleControlFrame(frame: Extract): void { this.queueMirror.replace(frame.items) + this.observeSubmissionQueue(frame.items) this.notifier.markDirty() } @@ -533,8 +565,13 @@ export class Session implements SessionFace { * @returns when the Remote iterator has completed teardown. */ async dispose(): Promise { + // Unsettled echoes retire as failed so their owners can restore or + // release browser resources; echoes already scheduled as observed keep + // that settlement. + for (const requestId of [...this.submissionSettlements.keys()]) { + this.retireFailedSubmission(requestId) + } this.openGeneration++ - this.cancelProjectionOpening() const events = this.events this.events = undefined await events?.dispose() @@ -552,10 +589,6 @@ export class Session implements SessionFace { if (generation !== this.openGeneration || this.events !== events) return this.acceptEventChange(change) }, - carrierFailed: () => { - if (generation !== this.openGeneration || this.events !== events) return - this.beginProjectionOpening() - }, failed: (error) => { this.failEventStream(events, generation, error) }, @@ -568,7 +601,6 @@ export class Session implements SessionFace { } catch (error) { if (generation !== this.openGeneration || this.events !== events) return this.events = undefined - this.cancelProjectionOpening() this.openState = 'error' this.openError = openFailure(error) } finally { @@ -591,24 +623,13 @@ export class Session implements SessionFace { } /** Replace the complete contiguous window and apply page-owned projection metadata. */ - private installWindow( - entries: readonly SessionEventLikeEntry[], - hasMore: boolean, - projections?: ProjectionsBaseline, - ): void { + private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.baseSeq = entries[0]?.event.seq ?? 0 this.hasMore = hasMore if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false - if (projections !== undefined) { - const opening = this.projectionOpening - /* v8 ignore next -- only follow snapshots carry projections, and every follow generation starts a token. */ - if (opening === undefined) throw new Error('projection baseline arrived outside an opening') - this.projections.completeOpening(opening, projections) - this.projectionOpening = undefined - } else { - this.cancelProjectionOpening() - } + if (projections !== undefined) this.projections.seed(projections) this.eventSource.replace(entries, hasMore) + for (const entry of entries) this.observeSubmissionEvent(entry.event) this.notifier.markDirty() } @@ -626,13 +647,73 @@ export class Session implements SessionFace { if (event.type === 'turn/start') this.firstPromptPendingTurn = false const queueChanged = this.queueMirror.acceptDurable(event) this.eventSource.append(entry) + // After the feed append: the conversation assembly's animation frame is + // registered by the feed subscribers above, so the echo-retirement frame + // scheduled here always runs after the durable node became renderable. + this.observeSubmissionEvent(event) return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn } + /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ + private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { + if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return + // Structural read: window entries may be compact history records, so the + // fields are narrowed rather than trusted (same posture as Conversation + // assembly matchers). + const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined + const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined + if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + } + + /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ + private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void { + if (this.submissionSettlements.size === 0) return + for (const item of items) { + if (item.rpcId !== undefined) { + this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + } + } + } + + /** + * Latch one observed settlement and remove the echo an animation frame + * later. The delay keeps the echo in the snapshot until the frame in which + * the durable node (whose assembly frame was registered first) is + * renderable; the render-time rpcId dedupe hides the one-frame overlap. + */ + private scheduleObservedRetirement( + requestId: SessionRequestId, + attachments: readonly ImageAttachmentRef[], + ): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }) }) + } + + /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ + private retireFailedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + this.finishSubmission(requestId, { reason: 'failed' }) + } + + /** Single removal point: drop the echo, publish, then notify the owner. */ + private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void { + const settlement = this.submissionSettlements.get(requestId) + /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */ + if (settlement === undefined) return + this.submissionSettlements.delete(requestId) + this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId) + this.notifier.markDirty() + settlement.onRetire?.(retirement) + } + /** Publish a terminal background failure only while this stream still owns the Session. */ private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { if (generation !== this.openGeneration || this.events !== events) return - this.cancelProjectionOpening() this.openGeneration++ this.events = undefined this.openPromise = null @@ -642,22 +723,11 @@ export class Session implements SessionFace { this.notifier.markDirty() } - /** Start one store-owned reconciliation interval, retaining it across repeated carrier failures. */ - private beginProjectionOpening(): void { - this.projectionOpening ??= this.projections.beginOpening() - } - - /** End the current reconciliation interval without altering visible values. */ - private cancelProjectionOpening(): void { - const opening = this.projectionOpening - this.projectionOpening = undefined - if (opening !== undefined) this.projections.cancelOpening(opening) - } - private buildSnapshot(): SessionSnapshot { return { sessionId: this.sessionId, queue: this.queueMirror.snapshot(), + pendingSubmissions: this.pendingSubmissions, running: this.running, subagent: this.address === undefined ? null @@ -685,6 +755,26 @@ export class Session implements SessionFace { } } +/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ +function scheduleFrame(fn: () => void): void { + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) + else setTimeout(fn, 0) +} + +/** Image attachment references in one structurally-read content block list, in block order. */ +function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { + if (!Array.isArray(content)) return [] + const refs: ImageAttachmentRef[] = [] + for (const block of content) { + if (typeof block !== 'object' || block === null) continue + const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } + if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef) + } + } + return refs +} + /** Convert a terminal Session stream failure to the Client error vocabulary. */ function openFailure(error: unknown): ClientFailure { const failure = sessionStreamFailure(error) diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 4068b5536d..624c9a9493 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -188,16 +188,24 @@ function queueItems( ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ...project('next-step').map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ] } +/** Prompt-RPC identity carried by a browser-submitted message's user source. */ +function promptRpcId(message: UserMessage): Pick { + const source = message.source + return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} +} + function jobView(job: JobSnapshot): SessionJob { return { id: job.id, diff --git a/packages/api/session-controller/src/file-references.ts b/packages/api/session-controller/src/file-references.ts new file mode 100644 index 0000000000..44d3417928 --- /dev/null +++ b/packages/api/session-controller/src/file-references.ts @@ -0,0 +1,42 @@ +/** Session Controller adapter for Agent-scoped file-reference discovery. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-file-reference' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `fileReferences` Remote namespace. */ + sessionFileReferences: SessionFileReferences + } +} + +/** Host Remote adapter over the composed file-reference provider. */ +export class SessionFileReferences extends TypertRemoteService { + static inject = ['fileReferences', 'typert'] + + /** @param ctx - Host context carrying the selected file-reference provider. */ + constructor(ctx: Context) { + super(ctx, 'sessionFileReferences', { namespace: 'fileReferences' }) + } + + /** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ + @Remote + list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + return this.ctx.fileReferences.list(agent, query, signal) + } +} + +export default SessionFileReferences diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a3ce201aaf..342dd977eb 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,9 +3,10 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' +import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { ApiSessionAgentController, inspectApiSession, @@ -14,9 +15,13 @@ import { import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' +import { SessionFileReferences } from './file-references.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' +import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' +import { SessionSkillCatalog } from './skill-catalog.ts' import type { + ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -30,6 +35,8 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, + SessionOpenWorkspacePathRequest, + SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -46,6 +53,8 @@ import type { export type * from './types.ts' export { ApiSessionNotFound } from './agent.ts' +export { SessionFileReferences } from './file-references.ts' +export { SessionSkillCatalog } from './skill-catalog.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -58,6 +67,16 @@ declare module '@deepseek-ai/cordis' { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} + +/** Host integrations replaceable by direct unit tests. */ +export interface SessionControllerInternals { + /** Native default-application handoff. */ + readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native handoff availability probe. */ + readonly canOpenPath?: () => boolean } /** Host service backing the generated `ctx.remote.session` namespace. */ @@ -76,6 +95,7 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), + nativeOpen: z.boolean(), }) private readonly agents: ApiSessionAgentController @@ -83,13 +103,15 @@ export class SessionController extends TypertRemoteService { private readonly controlState: SessionControlController private readonly history: SessionHistoryController private readonly listState: ApiSessionList + private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly canOpenPath: () => boolean private readonly promotions = new Set>() /** * @param ctx - Host context containing the Session capability assembly. * @param config - cold-list observation policy. */ - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) @@ -105,6 +127,11 @@ export class SessionController extends TypertRemoteService { ctx, config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, ) + this.openPath = internals.openPath ?? openNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + ctx.plugin(SessionFileReferences) + ctx.plugin(SessionSkillCatalog) ctx.on('session/created', (session) => { ctx.emit('api-session/added', this.listState.summaryFor(session)) @@ -214,6 +241,61 @@ export class SessionController extends TypertRemoteService { return this.commands.selectModel(request) } + /** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ + @Remote('modelCatalog') + modelCatalog(): Promise { + return buildModelCatalog(this.ctx) + } + + /** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenWorkspacePath(): boolean { + return this.canOpenPath() + } + + /** + * Open one path prepared by a Session-aware caller on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. + * @returns confirmation after the native opener accepts the path. + * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + */ + @Remote('openWorkspacePath') + async openWorkspacePath( + request: SessionOpenWorkspacePathRequest, + signal: AbortSignal, + ): Promise { + if (request.path.length === 0) { + throw new TypertRemoteFailure({ + code: 'bad-request', + message: 'session.openWorkspacePath requires a non-empty path', + details: {}, + }) + } + signal.throwIfAborted() + try { + await this.openPath(request.path, signal) + return { opened: true } + } catch (error: unknown) { + if (signal.aborted) { + throw new TypertRemoteFailure({ + code: 'cancelled', message: 'path open was aborted', details: {}, + }) + } + throw new TypertRemoteFailure({ + code: 'internal', + message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + } + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. @@ -310,5 +392,5 @@ export class SessionController extends TypertRemoteService { } -export { buildModelCatalog } from './catalog.ts' +export { buildModelCatalog } export default SessionController diff --git a/packages/api/session-controller/src/skill-catalog.ts b/packages/api/session-controller/src/skill-catalog.ts new file mode 100644 index 0000000000..3cd15669ff --- /dev/null +++ b/packages/api/session-controller/src/skill-catalog.ts @@ -0,0 +1,120 @@ +/** Session-addressed, cold-readable skill catalog Remote. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent-presets/types' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import { isUserInvocable } from '@deepseek-ai/dsh-skill' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { SkillListRequest, SkillListValue } from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the Session-addressed `skills` Remote namespace. */ + sessionSkillCatalog: SessionSkillCatalog + } +} + +/** Host service backing `ctx.remote.skills` without activating a cold Agent. */ +export class SessionSkillCatalog extends TypertRemoteService { + static inject = ['agents', 'sessionQuery', 'typert'] + + /** @param ctx - Host context carrying Session reads and optional skill/preset services. */ + constructor(ctx: Context) { + super(ctx, 'sessionSkillCatalog', { namespace: 'skills' }) + } + + /** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + */ + @Remote + async list(request: SkillListRequest, signal: AbortSignal): Promise { + void signal + const { sessionId } = request + let cwd: string | undefined + let agentPreset: string | undefined + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId) + if (observation.projections === undefined) { + throw new Error('skill catalog requires a projected Session observation') + } + cwd = observation.header.cwd + agentPreset = observation.projections.values.agentPreset ?? undefined + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw failure( + 'session-not-found', + `session "${sessionId}" not found`, + { sessionId }, + ) + } + throw failure( + 'internal', + `session "${sessionId}" could not be inspected: ${String(error)}`, + ) + } + if (cwd === undefined) { + throw failure('internal', `session "${sessionId}" has no project cwd`) + } + + const live = this.ctx.agents.get(sessionId) + const presets = this.ctx.get('agentPresets') + const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') + const skillRegistry = scoped ?? this.ctx.get('skills') + if (skillRegistry === undefined) { + throw failure( + 'internal', + 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', + ) + } + + const scope = await this.scopeFor(sessionId, agentPreset) + try { + const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) + return { + skills: skills.map(skill => ({ + name: skill.name, + description: skill.description, + ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, + modelInvocable: skill.invocation.modelInvocable, + })), + } + } catch (error: unknown) { + throw failure('internal', `skill listing failed: ${String(error)}`) + } + } + + /** Resolve a live or standing preset scope without creating an Agent. */ + private async scopeFor( + sessionId: SessionId, + agentPreset: string | undefined, + ): Promise { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) return live + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return undefined + try { + return await presets.standingKeyFor(agentPreset) + } catch { + // An unknown or unusable recorded preset falls back to the global registry. + return undefined + } + } +} + +/** Build one stable Remote failure with optional typed details. */ +function failure( + code: 'session-not-found' | 'internal', + message: string, + details: { readonly sessionId: SessionId } | Record = {}, +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +export default SessionSkillCatalog diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c045e00707..167937e3d3 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -223,6 +223,28 @@ export type SessionError = { } }[keyof SessionErrorDetailsMap] +/** Session-addressed request for the human-invocable skill catalog. */ +export interface SkillListRequest { + readonly sessionId: SessionId +} + +/** One skill available to the Session's human-facing composer. */ +export interface SkillEntry { + /** Kebab-case identifier referenced as `/name`. */ + readonly name: string + /** Short routing description. */ + readonly description: string + /** Optional extra routing guidance. */ + readonly whenToUse?: string + /** Whether the same skill is also advertised to the model. */ + readonly modelInvocable: boolean +} + +/** Human-invocable skills visible through one Session's composition. */ +export interface SkillListValue { + readonly skills: readonly SkillEntry[] +} + /** Session list request. */ export interface SessionListRequest { readonly cursor?: string @@ -340,6 +362,17 @@ export interface SessionCancelValue { readonly accepted: true } +/** Request to open one path prepared by a Session-aware caller on the Host desktop. */ +export interface SessionOpenWorkspacePathRequest { + /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ + readonly path: string +} + +/** Confirmation that the Host handed a workspace path to its native opener. */ +export interface SessionOpenWorkspacePathValue { + readonly opened: true +} + /** Client-minted prompt identity used to reconcile optimistic and durable messages. */ export type SessionRequestId = Branded<'session-request-id'> @@ -432,6 +465,8 @@ export type SessionFollowFrame = export interface SessionQueuedItem { readonly id: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */ + readonly rpcId?: SessionRequestId /** JSON-safe message fields consumed by pending-queue presentation. */ readonly message: { readonly id: MessageId diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 876786a759..2ecaff6804 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -1,8 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + ConnectionGeneration, ConnectionHandle, - HostDescription, } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError, @@ -16,13 +16,7 @@ import * as SessionClient from '../src/client/index.ts' import { ClientSessions } from '../src/client/sessions/service.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' -const DESCRIPTION: HostDescription = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } } const sid = (value: string): SessionId => value as SessionId @@ -34,7 +28,7 @@ interface Bench { readonly fiber: Fiber readonly sessions: ClientSessions dispatch(event: string, ...args: unknown[]): void - publishHost(description: HostDescription | undefined): void + publishGeneration(generation: ConnectionGeneration | undefined): void } const contexts = new Set() @@ -45,23 +39,22 @@ afterEach(async () => { contexts.clear() }) -async function mount(initialHost?: HostDescription): Promise { +async function mount(initialGeneration?: ConnectionGeneration): Promise { const ctx = new Context() contexts.add(ctx) await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const remote = fakeRemote(api) const listeners = new Map>() - const hostListeners = new Set<() => void>() - let host = initialHost + const generationListeners = new Set<() => void>() + let generation = initialGeneration const connection: ConnectionHandle = { - api, isLoopback: true, - hostDescription: { - getSnapshot: () => host, + generation: { + getSnapshot: () => generation, subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } }, }, rpc: { @@ -97,9 +90,9 @@ async function mount(initialHost?: HostDescription): Promise { dispatch: (event, ...args) => { for (const listener of listeners.get(event) ?? []) listener(...args as never[]) }, - publishHost: (description) => { - host = description - for (const listener of [...hostListeners]) listener() + publishGeneration: (next) => { + generation = next + for (const listener of [...generationListeners]) listener() }, } } @@ -148,7 +141,7 @@ describe('Session Controller Client apply', () => { it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(accept).toHaveBeenCalledWith({ @@ -180,7 +173,7 @@ describe('Session Controller Client apply', () => { }) it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(bench.sessions.list.getSnapshot().phase).toBe('ready') @@ -212,7 +205,7 @@ describe('Session Controller Client apply', () => { await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) - bench.publishHost(DESCRIPTION) + bench.publishGeneration(GENERATION) await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) }) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 9ce0c453a0..b4645dc2f3 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -69,6 +69,30 @@ describe('Session control queue projection', () => { await iterator.next() }) + it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => { + const { control, inbox } = await harness() + const identified = createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'req-42' as never }, + }) + inbox.append('next-turn', identified) + inbox.append('next-step', message('plain steering')) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') + const items = opened.value.value.queues['queue-session' as SessionId] ?? [] + expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([ + { id: identified.id, placement: 'queued', rpcId: 'req-42' }, + { id: items[1]?.id, placement: 'steering', rpcId: undefined }, + ]) + expect('rpcId' in (items[1] ?? {})).toBe(false) + + abort.abort() + await iterator.next() + }) + it('ignores inbox events without the exact live Agent session', async () => { const { ctx, control, agent, inbox } = await harness() const abort = new AbortController() diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index a0eae81529..cb6a635bef 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -1,9 +1,9 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo +// Test-local programmable Remote fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { - IApiClient, MessageId, - RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry, + MessageId, + RpcError, RpcResponse, SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -32,10 +32,8 @@ import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' const AVAILABLE_STREAM_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/h' } }), subscribe: () => () => {}, }, } @@ -124,7 +122,7 @@ export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { return api.sessionRemotes() } -export class FakeApiClient implements IApiClient { +export class FakeApiClient { /** Chronological call record: [method, payload]. */ readonly calls: { method: string; payload: unknown }[] = [] /** Session ids in physical follow-generation opening order. */ @@ -156,19 +154,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - onOpenPath: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ opened: true as const })) + onOpenWorkspacePath: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ opened: true as const })) private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] @@ -194,11 +181,6 @@ export class FakeApiClient implements IApiClient { onSubagentInterrupt: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ accepted: true as const })) - readonly host: IApiClient['host'] = { - describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), - } - onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -217,37 +199,6 @@ export class FakeApiClient implements IApiClient { onWorkspaceArchiveSession: (payload: unknown) => Promise> = payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) - // 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. - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) - - - readonly agentPresets: IApiClient['agentPresets'] = { - openDocument: (payload: { agentPreset: string }) => - this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly skills: IApiClient['skills'] = { - list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - } - - readonly settings: IApiClient['settings'] = { - openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly llm: IApiClient['llm'] = { - providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ - default: { provider: 'fixture', model: 'fixture' }, - routableProviders: [], - groups: [], - failures: [], - }))), - discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), - } - /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ sessionRemotes(): RuntimeRemotes { return { @@ -258,7 +209,17 @@ export class FakeApiClient implements IApiClient { execute: () => Promise.resolve({ ok: true, value: undefined }), }, session: { + canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)), list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + modelCatalog: () => Promise.resolve({ + ok: true, + value: { + default: { provider: 'fixture', model: 'fixture' }, + routableProviders: [], + groups: [], + failures: [], + }, + }), search: (payload, signal) => { this.lastSearchSignal = signal return this.remoteResult('session.search', payload, this.onSearch(payload)) @@ -275,6 +236,11 @@ export class FakeApiClient implements IApiClient { attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + openWorkspacePath: payload => this.record( + 'session.openWorkspacePath', + payload, + this.onOpenWorkspacePath(payload), + ), page: request => this.page(request), follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), diff --git a/packages/api/session-controller/tests/file-references.host.spec.ts b/packages/api/session-controller/tests/file-references.host.spec.ts new file mode 100644 index 0000000000..89e6e9f667 --- /dev/null +++ b/packages/api/session-controller/tests/file-references.host.spec.ts @@ -0,0 +1,20 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import { describe, expect, it, vi } from 'vitest' +import { SessionFileReferences } from '../src/file-references.ts' + +describe('SessionFileReferences', () => { + it('delegates the resolved Agent, query, and cancellation signal unchanged', async () => { + const ctx = new Context() + const candidates: FileReferenceCandidate[] = [{ path: 'src', kind: 'directory' }] + const list = vi.fn(() => Promise.resolve(candidates)) + ctx.provide('fileReferences', { list } as never) + const adapter = new SessionFileReferences(ctx) + const agent = { id: 'target' } as unknown as Agent + const signal = new AbortController().signal + + await expect(adapter.list(agent, 'sr', signal)).resolves.toBe(candidates) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) +}) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 7f920021a1..8b311de746 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -5,10 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { - SessionControlFrame, - SessionProjectionHints, -} from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts' @@ -24,7 +21,6 @@ type SummaryOver = Partial<{ cwd: string parentSessionId: SessionId origin: 'subagent' - projections: SessionProjectionHints }> function summary(sessionId: SessionId, over: SummaryOver = {}) { @@ -47,49 +43,6 @@ describe('SessionManager instances', () => { expect(session.getSnapshot().running).toBe(true) // list preceded instantiation }) - it('drops absent and resident instances and drains both fulfilled and rejected disposals', async () => { - const manager = makeManager() - await expect(manager.drop(S2)).resolves.toBeUndefined() - - const dropped = manager.get(S1) - const droppedDispose = vi.spyOn(dropped, 'dispose').mockResolvedValue(undefined) - await expect(manager.drop(S1)).resolves.toBeUndefined() - expect(droppedDispose).toHaveBeenCalledOnce() - expect(manager.get(S1)).not.toBe(dropped) - - const fulfilled = deferred() - const rejected = deferred() - const first = manager.get(S1) - const second = manager.get(S2) - const firstDispose = vi.spyOn(first, 'dispose').mockReturnValue(fulfilled.promise) - const secondDispose = vi.spyOn(second, 'dispose').mockReturnValue(rejected.promise) - const disposal = manager.dispose() - expect(firstDispose).toHaveBeenCalledOnce() - expect(secondDispose).toHaveBeenCalledOnce() - fulfilled.resolve(undefined) - rejected.reject(new Error('dispose failed')) - await expect(disposal).resolves.toBeUndefined() - }) - - it('cancels a pending catalog debounce when the manager is disposed', async () => { - vi.useFakeTimers() - try { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - await manager.refreshSubagents(S1) - manager.setSubagentCatalogOpen(S1, true) - await manager.refreshSubagents(S1) - const calls = api.callsOf('subagents.list').length - - manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) - await manager.dispose() - await vi.advanceTimersByTimeAsync(50) - - expect(api.callsOf('subagents.list')).toHaveLength(calls) - } finally { - vi.useRealTimers() - } - }) }) describe('list lifecycle', () => { @@ -198,41 +151,34 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) - it('prewarms cold titles without letting later hints override an authoritative frame', async () => { + it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() const manager = new SessionManager(fakeRemote(api)) - // A push frame landed before the list. Its authoritative value wins even - // when a later cache hint claims a higher sequence. + // A push frame landed before the list (S2's title is newer than the block's cut). manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) api.onList = () => Promise.resolve(ok({ items: [ { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, - { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 12, values: { title: 'List stale' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, ] as never[], })) await manager.refreshList() const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') + // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') - manager.handleSessionAdded({ - ...summary(S2, { updatedAt: 300 }), - projections: { asOfSeq: 15, values: { title: 'Added stale' } }, - }) - expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) - it('routes resident and list projections through one store across exact control replacements', async () => { + it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() - const session = manager.get(S1) const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) } frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) - expect(session.projections.get('title')).toBe('Unflushed') // The durable baseline says the host only knows up to seq 2: the phantom // row rode lost state and must drop, or last-wins pins it forever. @@ -244,112 +190,19 @@ describe('list lifecycle', () => { }, }) expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() - expect(session.projections.get('title')).toBeUndefined() frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') - // The next complete generation replaces even a different equal-sequence row. + // A baseline at or past the row's seq keeps it (nothing phantom to drop). frame({ type: 'baseline', value: { queues: {}, jobs: {}, - projections: { [S1]: { asOfSeq: 2, values: { title: 'Recovered' } } }, + projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, }, }) - expect(manager.getListSnapshot().items[0]?.title).toBe('Recovered') - expect(session.projections.get('title')).toBe('Recovered') - }) - - it('uses the latest list hint when a later control generation omits the cold Session', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ - items: [summary(S1, { - projections: { asOfSeq: 4, values: { title: 'Repaired cache' } }, - })] as never[], - })) - const manager = new SessionManager(fakeRemote(api)) - manager.handleControlFrame({ - type: 'projection', sessionId: S1, key: 'title', value: 'Stale live', seq: 9, - }) - - await manager.refreshList() - expect(manager.getListSnapshot().items[0]?.title).toBe('Stale live') - manager.handleControlFrame({ - type: 'baseline', value: { queues: {}, jobs: {}, projections: {} }, - }) - - expect(manager.getListSnapshot().items[0]?.title).toBe('Repaired cache') - }) - - it('accepts a repaired lower-sequence list hint after an omitted control baseline', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ - items: [summary(S1, { - projections: { asOfSeq: 12, values: { title: 'Older cache' } }, - })] as never[], - })) - const manager = new SessionManager(fakeRemote(api)) - await manager.refreshList() - manager.handleControlFrame({ - type: 'projection', sessionId: S1, key: 'title', value: 'Stale live', seq: 20, - }) - - const response = deferred>>() - api.onList = () => response.promise - const refresh = manager.refreshList() - manager.handleControlFrame({ - type: 'baseline', value: { queues: {}, jobs: {}, projections: {} }, - }) - expect(manager.getListSnapshot().items[0]?.title).toBe('Older cache') - - response.resolve(ok({ - items: [summary(S1, { - projections: { asOfSeq: 3, values: { title: 'Repaired lower cache' } }, - })] as never[], - })) - await refresh - expect(manager.getListSnapshot().items[0]?.title).toBe('Repaired lower cache') - }) - - it('replays a newer session-added hint over an in-flight list response', async () => { - const api = new FakeApiClient() - const response = deferred>>() - api.onList = () => response.promise - const manager = new SessionManager(fakeRemote(api)) - const refresh = manager.refreshList() - - manager.handleSessionAdded(summary(S1, { - projections: { asOfSeq: 8, values: { title: 'Later added hint' } }, - })) - response.resolve(ok({ - items: [summary(S1, { - projections: { asOfSeq: 9, values: { title: 'Earlier pull hint' } }, - })] as never[], - })) - await refresh - - expect(manager.getListSnapshot().items[0]?.title).toBe('Later added hint') - }) - - it('does not recreate a projection store for a Session removed during a list response', async () => { - const api = new FakeApiClient() - const response = deferred>>() - api.onList = () => response.promise - const manager = new SessionManager(fakeRemote(api)) - const refresh = manager.refreshList() - - manager.handleSessionRemoved(S1) - response.resolve(ok({ - items: [summary(S1, { - projections: { asOfSeq: 9, values: { title: 'Removed pull hint' } }, - })] as never[], - })) - await refresh - - expect(manager.getListSnapshot().items).toEqual([]) - manager.handleSessionAdded(summary(S1, { blank: true })) - expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() + expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') }) }) @@ -420,37 +273,6 @@ describe('Host Remote event routing', () => { }) describe('subagent catalogs', () => { - it('rejects missing, diagnostic, and mode-mismatched catalog selections', async () => { - const api = new FakeApiClient() - api.onSubagentList = () => Promise.resolve(remoteOk({ - entries: [ - { kind: 'diagnostic', id: S1, reason: 'corrupt' }, - { - kind: 'child', id: S2, mode: 'one-shot', activity: 'inactive', hasChildren: false, - }, - ] as never[], - parentAvailable: true, - })) - const manager = new SessionManager(fakeRemote(api)) - await manager.refreshSubagents(S1) - - expect(() => { - manager.selectSubagent({ - parentSessionId: S1, childSessionId: 'fk-missing' as SessionId, mode: 'continuable', - }) - }).toThrow('is not a healthy catalog child') - expect(() => { - manager.selectSubagent({ - parentSessionId: S1, childSessionId: S1, mode: 'continuable', - }) - }).toThrow('is not a healthy catalog child') - expect(() => { - manager.selectSubagent({ - parentSessionId: S1, childSessionId: S2, mode: 'continuable', - }) - }).toThrow('is not a healthy catalog child') - }) - it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [ @@ -550,60 +372,6 @@ describe('subagent catalogs', () => { } }) - it('cancels a pending membership refresh when the catalog closes', async () => { - vi.useFakeTimers() - try { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - await manager.refreshSubagents(S1) - manager.setSubagentCatalogOpen(S1, true) - await manager.refreshSubagents(S1) - const calls = api.callsOf('subagents.list').length - - manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) - manager.setSubagentCatalogOpen(S1, false) - await vi.advanceTimersByTimeAsync(50) - - expect(api.callsOf('subagents.list')).toHaveLength(calls) - } finally { - vi.useRealTimers() - } - }) - - it('publishes business and transport catalog failures with and without a prior catalog', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - - api.onSubagentList = () => Promise.resolve(remoteErr({ - code: 'internal', message: 'business failure', details: {}, - })) - await manager.refreshSubagents(S1) - expect(manager.getListSnapshot().subagentsByParent[S1]).toMatchObject({ - entries: [], state: 'error', error: { message: 'business failure' }, - }) - - api.onSubagentList = () => Promise.reject(new Error('first transport failure')) - await manager.refreshSubagents(S2) - expect(manager.getListSnapshot().subagentsByParent[S2]).toMatchObject({ - entries: [], state: 'error', error: { message: 'first transport failure' }, - }) - - const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(remoteOk({ - entries: [{ kind: 'diagnostic', id: S1, reason: 'unavailable' }] as never[], - parentAvailable: true, - })) - await manager.refreshSubagents(root) - api.onSubagentList = () => Promise.reject(new Error('later transport failure')) - await manager.refreshSubagents(root) - expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ - entries: [{ kind: 'diagnostic', id: S1 }], - parentAvailable: true, - state: 'error', - error: { message: 'later transport failure' }, - }) - }) - it('marks a loaded parent row expandable only for a direct subagent publication', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId @@ -691,7 +459,6 @@ describe('subagent catalogs', () => { kind: 'child', id: S2, mode: 'continuable', label: 'started', activity: 'inactive', hasChildren: false, }, - { kind: 'diagnostic', id: 'fk-diagnostic' as SessionId, reason: 'corrupt' }, ] as never[], parentAvailable: true, })) @@ -700,14 +467,6 @@ describe('subagent catalogs', () => { expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, activity: 'inactive' }, { kind: 'child', id: S2, activity: 'running' }, - { kind: 'diagnostic', id: 'fk-diagnostic' }, - ]) - - manager.handleSessionStatus(S1, true) - expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ - { kind: 'child', id: S1, activity: 'running' }, - { kind: 'child', id: S2, activity: 'running' }, - { kind: 'diagnostic', id: 'fk-diagnostic' }, ]) }) @@ -930,22 +689,6 @@ describe('remaining branches', () => { })]) }) - it('leaves the list unchanged for ordinary fork failures and folds transport throws', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - api.onFork = () => Promise.resolve(err({ code: 'internal', message: 'fork denied', details: {} })) - await expect(manager.fork({ sessionId: S1, atSeq: 4 })).resolves.toMatchObject({ - ok: false, error: { message: 'fork denied' }, - }) - expect(manager.getListSnapshot().items).toEqual([]) - - api.onFork = () => Promise.reject(new Error('fork wire down')) - await expect(manager.fork({ sessionId: S1 })).resolves.toMatchObject({ - ok: false, error: { code: 'internal', message: 'fork wire down' }, - }) - expect(manager.getListSnapshot().items).toEqual([]) - }) - it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) @@ -984,30 +727,6 @@ describe('remaining branches', () => { manager.handleSessionError(S2, '无实例') }) - it('ignores duplicate running and stale activity frames', () => { - const manager = makeManager() - manager.handleSessionAdded(summary(S1, { running: true, updatedAt: 500 })) - const before = manager.getListSnapshot().items[0] - - manager.handleSessionStatus(S1, true) - manager.handleSessionActivity(S1, 499) - - expect(manager.getListSnapshot().items[0]).toBe(before) - }) - - it('keeps unrelated blank rows unchanged when a first prompt engages one session', async () => { - const manager = makeManager() - manager.handleSessionAdded(summary(S1, { blank: true })) - manager.handleSessionAdded(summary(S2, { blank: true })) - - await expect(manager.get(S1).prompt([{ type: 'text', text: 'hello' }], 'queue')) - .resolves.toMatchObject({ ok: true }) - - const items = manager.getListSnapshot().items - expect(items.find(item => item.sessionId === S1)?.blank).toBe(false) - expect(items.find(item => item.sessionId === S2)?.blank).toBe(true) - }) - it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) @@ -1025,17 +744,16 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items).toBe(after.items) }) - it('enriches an existing summary with cwd, parentSessionId, and origin', () => { + it('carries parentSessionId from the added event into the lineage row', () => { const api = new FakeApiClient() const manager = new SessionManager(fakeRemote(api)) manager.handleSessionAdded(summary(S1, { blank: true })) - manager.handleSessionAdded(summary(S2, { blank: true })) manager.handleSessionAdded(summary(S2, { - blank: true, cwd: '/work/child', parentSessionId: S1, origin: 'subagent', + blank: true, parentSessionId: S1, origin: 'subagent', })) const items = manager.getListSnapshot().items expect(items.find(e => e.sessionId === S2)).toMatchObject({ - cwd: '/work/child', parentSessionId: S1, origin: 'subagent', depth: 1, + parentSessionId: S1, origin: 'subagent', depth: 1, }) }) }) @@ -1087,21 +805,6 @@ describe('connected generation', () => { }) expect(manager.getListSnapshot().currentAddress).toEqual(address) }) - - it('refreshes an open catalog across reconnect even when it is not selected', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - manager.setSubagentCatalogOpen(S1, true) - await manager.refreshSubagents(S1) - const catalogCalls = api.callsOf('subagents.list').length - - manager.handleConnected() - - await vi.waitFor(() => { - expect(api.callsOf('session.list')).toHaveLength(1) - expect(api.callsOf('subagents.list')).toHaveLength(catalogCalls + 1) - }) - }) }) describe('completed reminder', () => { @@ -1269,22 +972,6 @@ describe('background-job mirror', () => { expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) - it('keeps only non-empty job sets from a complete control baseline', () => { - const manager = makeManager() - manager.handleControlFrame({ - type: 'baseline', - value: { - queues: {}, projections: {}, - jobs: { [S1]: [], [S2]: [view({ id: 'pwsh-1', label: 'kept' })] as never[] }, - }, - }) - - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - expect(manager.getListSnapshot().jobsBySession[S2]).toEqual([ - view({ id: 'pwsh-1', label: 'kept' }), - ]) - }) - it('drops the rows when the session is removed, whichever stream lands first', () => { const manager = makeManager() manager.handleSessionAdded(summary(S1, { blank: true })) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 12a2ddb80c..64ee9d66f8 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -1,15 +1,22 @@ /** - * Projection value-store precedence plus the minimal Session opening lifecycle - * that creates and settles store-owned reconciliation tokens. + * Projection value store (push model; session-projection subsystem page: + * docs/subsystems/session-projection.md): the single + * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a + * newer push frame; a replayed frame cannot regress), capability absence as + * undefined, generation truncation, and the Session/manager wiring (tail-page + * seeding, control-stream projection routing pre- and post-instantiation, the + * list rows' title projection). */ -import { describe, expect, it, vi } from 'vitest' -import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' +import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { SessionManager } from '../src/client/sessions/manager.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' import { entries, plainTurn } from './event-script.client.ts' +// Test-domain keys merged into the projection map (the Service Definition package's +// pure-type outlet), the same way domain host plugins merge theirs. declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { 'test/marks': { marks: string[] } @@ -19,187 +26,69 @@ declare module '@deepseek-ai/dsh-session-projection/types' { const SID = 'fk-s1' as SessionId describe('Session projection value semantics', () => { - it('reads undefined until a value lands and keeps stable observable faces', () => { + it('reads undefined until a value lands (capability absence)', () => { const store = new ProjectionValueStore() expect(store.get('test/marks')).toBeUndefined() expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined() - expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks')) }) - it('orders tentative hints by arrival, then lets authoritative frames win by sequence', () => { + it('applies frames last-wins by seq: replayed and stale frames drop', () => { const store = new ProjectionValueStore() - store.prewarm({ - asOfSeq: 9, - values: { 'test/marks': { marks: ['hint-9'] }, 'hint-only': 'hint' }, - }) - store.prewarm({ - asOfSeq: 8, - values: { 'test/marks': { marks: ['older-hint'] } }, - }) - expect(store.get('test/marks')).toEqual({ marks: ['older-hint'] }) - - store.apply('test/marks', { marks: ['frame-3'] }, 3) - store.prewarm({ - asOfSeq: 12, - values: { 'test/marks': { marks: ['late-hint'] }, 'other-hint': 'other' }, - }) - store.apply('test/marks', { marks: ['equal-frame'] }, 3) - store.apply('test/marks', { marks: ['newer-frame'] }, 4) - - expect(store.values()).toEqual({ - 'test/marks': { marks: ['newer-frame'] }, - 'hint-only': 'hint', - 'other-hint': 'other', - }) + store.apply('test/marks', { marks: ['a'] }, 5) + store.apply('test/marks', { marks: ['a', 'b'] }, 9) + expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] }) + store.apply('test/marks', { marks: ['stale'] }, 5) + store.apply('test/marks', { marks: ['equal'] }, 9) + expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] }) }) - it('resets a Session omitted from a control generation to its retained list hint', () => { + it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => { const store = new ProjectionValueStore() - store.replaceControlBaseline({ - asOfSeq: 9, - values: { 'test/marks': { marks: ['old-control'] }, 'control-only': true }, - }) - - store.replaceControlOmission({ - asOfSeq: 4, - values: { 'test/marks': { marks: ['repaired-cache'] } }, - }) - expect(store.values()).toEqual({ 'test/marks': { marks: ['repaired-cache'] } }) - - store.apply('test/marks', { marks: ['authoritative'] }, 3) - store.prewarm({ asOfSeq: 99, values: { 'test/marks': { marks: ['late-cache'] } } }) - expect(store.get('test/marks')).toEqual({ marks: ['authoritative'] }) - - store.replaceControlOmission() - expect(store.values()).toEqual({}) + store.apply('test/marks', { marks: ['frame-20'] }, 20) + // Stale cut: carried key loses to the newer frame; omitted key survives. + store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } }) + expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) + store.seed({ asOfSeq: 15, values: {} }) + expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) + // Fresh cut: carried key reseeds… + store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } }) + expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] }) + // …and an omitting fresh cut clears (capability absent as of the cut). + store.seed({ asOfSeq: 40, values: {} }) + expect(store.get('test/marks')).toBeUndefined() }) - it('opening replaces pre-opening state and retains only newer post-token frames', () => { + it('truncate drops rows past the durable baseline and keeps the rest', () => { const store = new ProjectionValueStore() - store.prewarm({ asOfSeq: 20, values: { 'test/marks': { marks: ['hint'] } } }) - store.apply('pre-opening', 'old-frame', 30) - const opening = store.beginOpening() - store.apply('test/marks', { marks: ['post-token'] }, 3) - store.apply('discarded', 'at-cut', 10) - store.apply('retained', 'new-frame', 11) - - store.completeOpening(opening, { - asOfSeq: 10, - values: { - 'test/marks': { marks: ['opening'] }, - 'opening-only': 'present', - }, - }) - - expect(store.values()).toEqual({ - 'test/marks': { marks: ['opening'] }, - 'opening-only': 'present', - retained: 'new-frame', - }) + store.apply('test/marks', { marks: ['durable'] }, 5) + store.apply('other', 'phantom', 50) + store.truncate(10) + expect(store.get('test/marks')).toEqual({ marks: ['durable'] }) + expect(store.get('other')).toBeUndefined() }) - it('a control baseline exactly replaces equal-sequence and omitted rows', () => { - const store = new ProjectionValueStore() - store.apply('title', 'transient', 1) - store.apply('omitted', 'transient', 1) - - store.replaceControlBaseline({ asOfSeq: 1, values: { title: null } }) - - expect(store.values()).toEqual({ title: null }) - store.apply('title', 'equal-frame', 1) - expect(store.get('title')).toBeNull() - store.apply('title', 'durable-frame', 2) - expect(store.get('title')).toBe('durable-frame') - }) - - it('an equal or newer control baseline received during opening wins', () => { - const store = new ProjectionValueStore() - const opening = store.beginOpening() - store.replaceControlBaseline({ - asOfSeq: 5, - values: { 'test/marks': { marks: ['control'] }, 'control-only': 'present' }, - }) - store.apply('test/marks', { marks: ['after-control'] }, 6) - - store.completeOpening(opening, { - asOfSeq: 5, - values: { 'test/marks': { marks: ['opening'] }, 'opening-only': 'discarded' }, - }) - - expect(store.values()).toEqual({ - 'test/marks': { marks: ['after-control'] }, - 'control-only': 'present', - }) - }) - - it('a newer opening replaces an older control baseline but keeps later frames', () => { - const store = new ProjectionValueStore() - const opening = store.beginOpening() - store.replaceControlBaseline({ - asOfSeq: 5, - values: { 'test/marks': { marks: ['control'] }, 'control-only': 'discarded' }, - }) - store.apply('stale-frame', 'discarded', 6) - store.apply('retained-frame', 'present', 11) - - store.completeOpening(opening, { - asOfSeq: 10, - values: { 'test/marks': { marks: ['opening'] }, 'opening-only': 'present' }, - }) - - expect(store.values()).toEqual({ - 'test/marks': { marks: ['opening'] }, - 'opening-only': 'present', - 'retained-frame': 'present', - }) - }) - - it('ignores hints after a complete baseline and rejects stale opening tokens', () => { - const store = new ProjectionValueStore() - const stale = store.beginOpening() - const current = store.beginOpening() - store.completeOpening(stale, { asOfSeq: 1, values: { stale: true } }) - expect(store.values()).toEqual({}) - store.cancelOpening(stale) - store.completeOpening(current, { asOfSeq: 1, values: { exact: true } }) - store.prewarm({ asOfSeq: 99, values: { exact: false, late: true } }) - expect(store.values()).toEqual({ exact: true }) - }) - - it('a canceled opening makes its frames pre-opening for the next exact cut', () => { - const store = new ProjectionValueStore() - const failed = store.beginOpening() - store.apply('test/marks', { marks: ['failed-frame'] }, 3) - store.cancelOpening(failed) - const retry = store.beginOpening() - store.completeOpening(retry, { - asOfSeq: 2, - values: { 'test/marks': { marks: ['retry'] } }, - }) - expect(store.get('test/marks')).toEqual({ marks: ['retry'] }) - }) - - it('notifies accepted changes but not dropped authoritative frames', async () => { + it('notifies the key face on change (batched) and not on dropped applications', async () => { const store = new ProjectionValueStore() let keyTicks = 0 let anyTicks = 0 store.faceOf('test/marks').subscribe(() => { keyTicks += 1 }) store.subscribeAny(() => { anyTicks += 1 }) - const value = { marks: ['a'] } - store.apply('test/marks', value, 1) + store.apply('test/marks', { marks: ['a'] }, 5) await Promise.resolve() expect(keyTicks).toBe(1) expect(anyTicks).toBe(1) - store.apply('test/marks', { marks: ['replay'] }, 0) - store.replaceControlBaseline({ asOfSeq: 5, values: { 'test/marks': value } }) - store.apply('test/marks', { marks: ['stale-after-baseline'] }, 4) + store.apply('test/marks', { marks: ['replay'] }, 3) await Promise.resolve() expect(keyTicks).toBe(1) expect(anyTicks).toBe(1) - expect(store.get('test/marks')).toBe(value) }) - it('publishes one stable whole-value snapshot until an observable row changes', () => { + it('faces are identity-stable per key (the React binding cache premise)', () => { + const store = new ProjectionValueStore() + expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks')) + }) + + it('publishes one reference-stable whole-value snapshot until a row changes', () => { const store = new ProjectionValueStore() const empty = store.values() expect(store.values()).toBe(empty) @@ -211,68 +100,123 @@ describe('Session projection value semantics', () => { }) }) -describe('Session opening integration', () => { - it('retains a tentative hint when opening fails, then replaces it on retry', async () => { +describe('Session tail-page seeding', () => { + it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const projections = new ProjectionValueStore() - projections.prewarm({ asOfSeq: 5, values: { 'test/marks': { marks: ['cached'] } } }) - const session = new Session(SID, fakeRemote(api), { projections }) - api.onHistory = () => Promise.resolve(err({ - code: 'session-not-found', - message: 'gone', - details: { sessionId: SID }, - })) - - await session.open() - expect(session.getSnapshot().openState).toBe('error') - expect(session.projections.get('test/marks')).toEqual({ marks: ['cached'] }) - - api.onHistory = () => Promise.resolve(ok({ - records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, - projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['exact'] } } }, - } as never)) - await session.open() - expect(session.projections.get('test/marks')).toEqual({ marks: ['exact'] }) - }) - - it('preserves an authoritative frame received while the opening is in flight', async () => { - const api = new FakeApiClient() - const history = deferred>>() - api.onHistory = () => history.promise const session = new Session(SID, fakeRemote(api)) - - const opening = session.open() - session.projections.apply('test/marks', { marks: ['live'] }, 3) - history.resolve(ok({ - records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, - projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['opening'] } } }, + api.onHistory = () => Promise.resolve(ok({ + records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, + projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, } as never)) - await opening - - expect(session.projections.get('test/marks')).toEqual({ marks: ['live'] }) + await session.open() + expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] }) }) - it('starts a fresh opening token while a carrier reconnect awaits its snapshot', async () => { + it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, - projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['first'] } } }, + projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, } as never)) await session.open() + session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9) + await session.resync() + expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] }) + }) - const replacement = deferred>>() - api.onHistory = () => replacement.promise - api.failStreams(new RemoteStreamCarrierError('carrier lost')) - await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) }) - session.projections.apply('test/marks', { marks: ['during-retry'] }, 3) - replacement.resolve(ok({ - records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, - projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['replacement'] } } }, - } as never)) - - await vi.waitFor(() => { - expect(session.projections.get('test/marks')).toEqual({ marks: ['during-retry'] }) - }) + it('treats a blockless response as no reset: pushed values survive', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api)) + api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) + await session.open() + session.projections.apply('test/marks', { marks: ['pushed'] }, 9) + await session.resync() + expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] }) + }) +}) + +describe('manager frame routing', () => { + const sid = (s: string): SessionId => s as SessionId + + it('lands projection frames before instantiation and the Session adopts the same store', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7, + }) + const session = manager.get(sid('s1')) + expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] }) + // Frames after instantiation land in the same store. + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9, + }) + expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] }) + }) + + it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], + }) as never) + await manager.refreshList() + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title') + // The durable baseline says the host only knows up to seq 2: the row rode + // lost state and must drop (the un-flushed title precedent). + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, + }, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() + }) + + it('projects every retained value into list rows with stable snapshot identity', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + api.onList = () => Promise.resolve(ok({ + items: [{ + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, + projections: { + asOfSeq: 2, + values: { 'test/marks': { marks: ['baseline'] } }, + }, + }], + }) as never) + await manager.refreshList() + const baseline = manager.getListSnapshot().items[0]?.projectionValues + expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) + + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.projectionValues) + .toEqual({ 'test/marks': { marks: ['live'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline) + }) + + it('drops the projection store with the removed session', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], + }) as never) + await manager.refreshList() + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4, + }) + manager.handleSessionRemoved(sid('s1')) + expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined() }) }) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index d203585459..bc67a94ca6 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -305,9 +305,9 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) - const catalog = await buildModelCatalog(ctx) + const catalog = expectValue(await remote.modelCatalog()) expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deepseek-official', model: 'private-preview', diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts new file mode 100644 index 0000000000..2c1feb292f --- /dev/null +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -0,0 +1,140 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import { describe, expect, it, vi } from 'vitest' +import { + createSessionTestController, + createSessionTestRemote, +} from './test-remote.ts' + +async function context(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +describe('session/openWorkspacePath', () => { + it('reports the deployment opener capability independently of a Session', async () => { + const ctx = await context() + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + canOpenPath: () => false, + }) + + await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + }) + + it('derives opener availability from config, an injected opener, or the platform probe', async () => { + const configured = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + nativeOpen: false, + }) + await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + + const injected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath: () => Promise.resolve(), + }) + await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) + + const detected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true }) + }) + + it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { + const ctx = await context() + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + const signal = new AbortController().signal + + await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal)) + .resolves.toEqual({ ok: true, value: { opened: true } }) + expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal) + expect(ctx.agents.list()).toEqual([]) + }) + + it('preserves relative and absolute Host-resolvable paths', async () => { + const ctx = await context() + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await remote.openWorkspacePath({ path: '/tmp/result.html' }) + await remote.openWorkspacePath({ path: 'result.html' }) + expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html']) + }) + + it('rejects empty paths before opening anything', async () => { + const ctx = await context() + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await expect(remote.openWorkspacePath({ path: '' })) + .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + expect(openPath).not.toHaveBeenCalled() + }) + + it('preserves native opener failure and cancellation results', async () => { + const ctx = await context() + const openPath = vi.fn((_path: string, _signal: AbortSignal) => + Promise.reject(new Error('desktop unavailable'))) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await expect(remote.openWorkspacePath({ path: 'result.html' })) + .resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'path open failed: desktop unavailable' }, + }) + + const aborted = new AbortController() + aborted.abort(new Error('cancelled')) + await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal)) + .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + + it('classifies opener cancellation and non-Error failures', async () => { + const ctx = await context() + const aborted = new AbortController() + const openPath = vi.fn() + .mockImplementationOnce(async () => { + aborted.abort(new Error('cancelled')) + throw new Error('opening stopped') + }) + .mockRejectedValueOnce('desktop unavailable') + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + await expect(controller.openWorkspacePath({ + path: 'second.html', + }, new AbortController().signal)).rejects.toMatchObject({ + failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts new file mode 100644 index 0000000000..261bddbb78 --- /dev/null +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -0,0 +1,258 @@ +/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { Session } from '../src/client/sessions/session.ts' +import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' +import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' +import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { historyValue } from './event-script.client.ts' + +const SID = 'fk-s1' as SessionId + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { + return { api, session: new Session(SID, fakeRemote(api)) } +} + +function imageRef(id: string): ImageAttachmentRef { + return { + attachmentId: id, + mediaType: 'image/png', + bytes: 1, + width: 2, + height: 2, + } as unknown as ImageAttachmentRef +} + +/** A durable browser-prompt user/message whose source echoes `rpcId`. */ +function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { + return { + seq, + time: 1_700_000_000_000 + seq, + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [ + ...refs.map(attachment => ({ type: 'image' as const, attachment })), + { type: 'text' as const, text: '发送' }, + ], + source: { kind: 'user', rpcId }, + }), + } as unknown as SessionEvent +} + +function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { + return { + id: 'm-queued' as SessionQueuedItem['id'], + placement: 'queued', + rpcId, + message: { + id: 'm-queued' as SessionQueuedItem['id'], + content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + }, + } +} + +/** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ +function settleFrames(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('beginSubmission', () => { + it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) + const handle = session.beginSubmission({ + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }) + expect(session.getSnapshot().promptAttempted).toBe(true) + expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ + requestId: handle.requestId, + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }]) + }) + + it('abandon retires the echo as failed exactly once', () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '放弃', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + handle.abandon() + handle.abandon() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) +}) + +describe('prompt-coupled retirement', () => { + it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '失败的', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) + expect(result.ok).toBe(false) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' }) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) + + it('sends the echo identity as the prompt requestId', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '带 id', images: [] }) + await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) + expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) + }) + + it('an unidentified prompt failure leaves registered echoes alone', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + session.beginSubmission({ text: '还在', images: [] }) + await session.prompt([{ type: 'text', text: '另一个' }], 'queue') + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + }) +}) + +describe('observed retirement', () => { + it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '发送', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-1')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never }) + // Synchronously after the append the echo is still in the snapshot; the + // render-time dedupe owns the overlap frame. + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '排队', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-q')] + session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + // The queue projection keeps the correlation id for render-time dedupe. + expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) + }) + + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '重连', images: [] }) + api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) + await session.open() + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) + + it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '先观察', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + handle.abandon() + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) + }) + + it('retires once when the queue and durable event report the same request id', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '同一请求', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + session.handleControlFrame({ + type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])], + }) + await api.pushFollow(SID, { + type: 'event', event: promptEvent(0, handle.requestId) as never, + }) + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) + + it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + frames.push(fn) + return frames.length + }) + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const handle = session.beginSubmission({ text: '帧', images: [] }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(frames).toHaveLength(1) + frames[0]?.(0) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) + +describe('disposal', () => { + it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] + const observed = session.beginSubmission({ + text: '已观察', + images: [], + onRetire: retirement => retirements.push({ text: '已观察', retirement }), + }) + session.beginSubmission({ + text: '未settle', + images: [], + onRetire: retirement => retirements.push({ text: '未settle', retirement }), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never }) + await session.dispose() + await settleFrames() + expect(retirements).toEqual([ + { text: '未settle', retirement: { reason: 'failed' } }, + { text: '已观察', retirement: { reason: 'observed', attachments: [] } }, + ]) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 6b62c6466b..fd30f66158 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -342,18 +342,12 @@ describe('session.list projections column', () => { it('lists the latest preset selected by a blank Session instead of its creation preset', async () => { const { ctx } = await harness(true) - ctx.sessionProjections.register(agentPresetProjectionDefinition) const session = ctx.sessions.create(SessionId('preset-list'), { meta: { cwd: '/workspace', agentPreset: 'standard' }, }) + ctx.sessionProjections.register(agentPresetProjectionDefinition) const gateway = remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) - - const initial = await gateway.list(request({})) - if (!initial.ok) throw new Error('unreachable') - expect(initial.value.items.find(item => item.sessionId === session.id) - ?.projections?.values.agentPreset).toBe('standard') - session.append('agent-preset/selected', { agentPreset: 'minimal' }) const response = await gateway.list(request({})) diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts new file mode 100644 index 0000000000..5b5e1b2a5a --- /dev/null +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -0,0 +1,225 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type {} from '@deepseek-ai/dsh-skill' +import { describe, expect, it, vi } from 'vitest' +import { SessionSkillCatalog } from '../src/skill-catalog.ts' + +function observation( + sessionId: SessionId, + options: { readonly cwd?: string; readonly agentPreset?: string } = {}, +): SessionObservation { + const events = Object.freeze([]) + const lease = (): SessionObservation => ({ + source: 'live', + header: { + version: 0, + id: sessionId, + createdAt: 1, + ...options.cwd === undefined ? {} : { cwd: options.cwd }, + }, + events, + cursor: -1, + projections: { + asOfSeq: -1, + values: { + ...options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }, + }, + }, + retain: lease, + [Symbol.dispose]: () => {}, + }) + return lease() +} + +async function context(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +describe('SessionSkillCatalog', () => { + it('reads a cold Session catalog without resuming an Agent', async () => { + const ctx = await context() + const sessionId = SessionId('cold-skills') + const observed = observation(sessionId, { cwd: '/cold/project' }) + const dispose = vi.spyOn(observed, Symbol.dispose) + const observeSession = vi.fn(() => Promise.resolve(observed)) + ctx.provide('sessionQuery', { observeSession } as never) + const resume = vi.spyOn(ctx.agents, 'resume') + const list = vi.fn(() => Promise.resolve([ + { + name: 'review', + description: 'Review the current change.', + whenToUse: 'Before publishing.', + invocation: { modelInvocable: true, userInvocable: true }, + }, + { + name: 'model-only', + description: 'Not shown to the user.', + invocation: { modelInvocable: true, userInvocable: false }, + }, + ])) + ctx.provide('skills', { list } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ + skills: [{ + name: 'review', + description: 'Review the current change.', + whenToUse: 'Before publishing.', + modelInvocable: true, + }], + }) + expect(observeSession).toHaveBeenCalledWith(sessionId) + expect(dispose).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.list()).toEqual([]) + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) + }) + + it('uses a live Agent to address a preset-owned registry', async () => { + const ctx = await context() + const sessionId = SessionId('live-skills') + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/live/project' } }) + const agent = { id: sessionId, session, status: 'idle', ctx } as Agent + ctx.agents.register(agent) + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/live/project' })), + } as never) + const scopedList = vi.fn(() => Promise.resolve([{ + name: 'preset-owned', + description: 'Composed for this Agent.', + invocation: { modelInvocable: false, userInvocable: true }, + }])) + const standingKeyFor = vi.fn() + ctx.provide('agentPresets', { + serviceFor: () => ({ list: scopedList }), + standingKeyFor, + } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ + skills: [{ + name: 'preset-owned', + description: 'Composed for this Agent.', + modelInvocable: false, + }], + }) + expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/project', scope: agent }) + expect(standingKeyFor).not.toHaveBeenCalled() + }) + + it('uses the recorded preset standing scope for a cold Session', async () => { + const ctx = await context() + const sessionId = SessionId('standing-skills') + const scope = { agentPreset: 'minimal' } + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { + cwd: '/cold/project', + agentPreset: 'minimal', + })), + } as never) + const standingKeyFor = vi.fn(() => Promise.resolve(scope)) + ctx.provide('agentPresets', { standingKeyFor } as never) + const list = vi.fn(() => Promise.resolve([])) + ctx.provide('skills', { list } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) + expect(standingKeyFor).toHaveBeenCalledWith('minimal') + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('falls back to the global registry when the recorded preset is unavailable', async () => { + const ctx = await context() + const sessionId = SessionId('gone-preset') + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { + cwd: '/cold/project', + agentPreset: 'gone', + })), + } as never) + ctx.provide('agentPresets', { + standingKeyFor: () => Promise.reject(new Error('unknown preset')), + } as never) + const list = vi.fn(() => Promise.resolve([])) + ctx.provide('skills', { list } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) + }) + + it.each([ + { + error: new SessionQueryError( + 'session "missing-skills" not found', + 'SESSION_QUERY_SESSION_NOT_FOUND', + ), + code: 'session-not-found', + }, + { error: new Error('storage offline'), code: 'internal' }, + ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => { + const ctx = await context() + ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list( + { sessionId: SessionId('missing-skills') }, + new AbortController().signal, + )).rejects.toMatchObject({ failure: { code } }) + }) + + it('reports an absent skill registry instead of an empty catalog', async () => { + const ctx = await context() + const sessionId = SessionId('no-skills') + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })), + } as never) + const catalog = new SessionSkillCatalog(ctx) + + const failed = catalog.list({ sessionId }, new AbortController().signal) + await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failed).rejects.toThrow('skill registry is absent') + }) + + it('rejects observations without projections or a project cwd', async () => { + const ctx = await context() + const sessionId = SessionId('incomplete-skills') + const withoutProjections = { ...observation(sessionId, { cwd: '/project' }), projections: undefined } + const observeSession = vi.fn() + .mockResolvedValueOnce(withoutProjections) + .mockResolvedValueOnce(observation(sessionId)) + ctx.provide('sessionQuery', { observeSession } as never) + const catalog = new SessionSkillCatalog(ctx) + + const unprojected = catalog.list({ sessionId }, new AbortController().signal) + await expect(unprojected).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(unprojected).rejects.toThrow('projected Session observation') + const cwdless = catalog.list({ sessionId }, new AbortController().signal) + await expect(cwdless).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(cwdless).rejects.toThrow('has no project cwd') + }) + + it('classifies a provider listing failure', async () => { + const ctx = await context() + const sessionId = SessionId('failed-skills') + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })), + } as never) + ctx.provide('skills', { + list: () => Promise.reject(new Error('catalog offline')), + } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac131ab628..e2e6292bce 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -19,6 +19,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import SessionController from '../src/index.ts' import type { + ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -32,6 +33,8 @@ import type { SessionFollowRequest, SessionListRequest, SessionListValue, + SessionOpenWorkspacePathRequest, + SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -48,16 +51,22 @@ import type { /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { + canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> selectModel(request: SessionSelectModelRequest): Promise> + modelCatalog(): Promise> rename(request: SessionRenameRequest): Promise> fork(request: SessionForkRequest): Promise> prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> attachment(request: SessionAttachmentRequest): Promise> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> + openWorkspacePath( + request: SessionOpenWorkspacePathRequest, + signal?: AbortSignal, + ): Promise> page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable @@ -68,7 +77,10 @@ export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string readonly coldBlankProbeMaxBytes?: number + readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise + readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly canOpenPath?: () => boolean } const installed = new WeakMap() @@ -174,9 +186,19 @@ function installControllers( const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { - controller = new SessionController(ctx, defaults.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }) + controller = new SessionController( + ctx, + { + ...defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, + ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + }, + { + ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, + }, + ) } finally { cwd.mockRestore() } @@ -220,6 +242,7 @@ export function createSessionTestRemote( ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { + canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), signal, @@ -230,6 +253,7 @@ export function createSessionTestRemote( ), create: request => remoteResult(() => direct.create(request)), selectModel: request => remoteResult(() => direct.selectModel(request)), + modelCatalog: () => remoteResult(() => direct.modelCatalog()), rename: request => remoteResult(() => direct.rename(request)), fork: request => remoteResult(() => direct.fork(request)), prompt: (request, signal = new AbortController().signal) => remoteResult( @@ -239,6 +263,10 @@ export function createSessionTestRemote( attachment: request => remoteResult(() => direct.attachment(request)), updateQueue: request => remoteResult(() => direct.updateQueue(request)), cancel: request => remoteResult(() => direct.cancel(request)), + openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult( + () => direct.openWorkspacePath(request, signal), + signal, + ), page: (request, signal = new AbortController().signal) => remoteResult( () => direct.page(request, signal), signal, diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 1ad1d4e858..36afa47632 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -28,10 +28,8 @@ type SessionTransportRemote = Pick const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never } const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 775fadecd5..8461d99a24 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -163,16 +163,11 @@ describe('SessionHistoryController', () => { [Symbol.asyncIterator]() const opening = iterator.next() - const unrelatedId = SessionId('unrelated') ctx.emit('session/event', { - id: unrelatedId, - header: { version: 0, id: unrelatedId, createdAt: 1 }, - events: [event('fixture/other', 0)], + id: SessionId('unrelated'), events: [event('fixture/other', 0)], } as unknown as Session, event('fixture/other', 0)) ctx.emit('session/event', { - id: sessionId, - header, - events: [event('fixture/start', 0)], + id: sessionId, events: [event('fixture/start', 0)], } as unknown as Session, event('fixture/start', 0)) inspected.resolve({ meta: header, events: [event('fixture/start', 0)] }) await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -299,7 +294,6 @@ describe('SessionHistoryController', () => { const gap = event('fixture/gap', 2) live.ctx.emit('session/event', { id: session.id, - header: session.header, events: [event('fixture/start', 0), skipped, gap], } as unknown as Session, gap) await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 219893c980..030ed712d1 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -16,6 +16,7 @@ { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../client/store" }, + { "path": "../../context/file-reference" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index d3256426e2..bea21672b7 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -14,9 +14,11 @@ "src/catalog.ts", "src/commands.ts", "src/control.ts", + "src/file-references.ts", "src/history.ts", "src/list.ts", - "src/model-selection-projection.ts" + "src/model-selection-projection.ts", + "src/skill-catalog.ts" ], "references": [ { "path": "../../../vendor/cordis" }, @@ -25,10 +27,12 @@ { "path": "../../core/agent-default-model" }, { "path": "../../core/scope" }, { "path": "../../core/session" }, + { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, + { "path": "../../util/native-command" }, { "path": "../../preset/agent-presets" }, { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../session/session-persistence" }, @@ -36,6 +40,7 @@ { "path": "../../session/session-projection-cache" }, { "path": "../../session/session-title" }, { "path": "../../session-query/session-query" }, + { "path": "../../skill/skill" }, { "path": "../../subagent/subagent" }, { "path": "../../typert/protocol" }, { "path": "../../typert/registry" }, diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index f393e41d2e..cc5d27a374 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/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 packages/api/settings-controller/README.md -README.md: f57bab1cf68ff05d807102a831f4ad9ce65dba76 -README.zh.md: 41062db004b8c98f544f70c42137a71aee997ec8 +README.md: 5032b8ff352d35abc05384719932691a93a49f84 +README.zh.md: 756a7fcdd1cd03b157ff5781aadd4db092c6d374 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index f57bab1cf6..5032b8ff35 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -1,5 +1,5 @@ --- -description: "Host Remote owner for settings and credential configuration surfaces, including redacted reads, path-addressed settings writes, and credential reference management." +description: "Host Remote owner for settings and credential configuration surfaces, including redacted reads, writes, credential references, and native document opening." kind: "package-reference" --- # Settings Controller @@ -8,11 +8,12 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports merge, replacement, and path-addressed settings writes, and stores or removes credential references without returning secret values. When either provider is absent, its namespace remains registered and returns an actionable configuration error. +`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports settings and credential writes without returning secret values, and opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. ## Table of Contents - [Use this package](#use-this-package) +- [Configuration](#configuration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -28,6 +29,19 @@ Mount this package as a Loader entry in a profile that serves browser configurat `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. +`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.canOpenAgentPresetDirectory()` reports native-opening availability when the preset page becomes visible. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither open method accepts a browser-supplied filesystem target. + +----- + + +## Configuration + +| Field | Default | Meaning | +|---|---|---| +| `nativeOpen` | platform-detected | Whether Agent preset directories can be handed to a native desktop opener | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-settings-controller) is the exhaustive source for accepted fields and their JSDoc. + ----- @@ -43,7 +57,6 @@ No direct effect; reading or writing these configuration values does not alter m -- Settings document opening uses API Proxy rather than this Remote namespace. - The batch bound is fixed at 64 references and is not a deployment-configurable field. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 41062db004..756a7fcdd1 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -1,5 +1,5 @@ --- -description: "settings 与凭据配置界面的 Host Remote owner,涵盖脱敏读取、按路径写入 settings 和管理凭据引用。" +description: "settings 与凭据配置界面的 Host Remote owner,涵盖脱敏读取、写入、凭据引用与原生文档打开。" kind: "package-reference" --- # Settings Controller @@ -8,11 +8,12 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持合并、替换和按路径表达的 settings 写入,并在不返回密钥值的前提下写入或移除凭据引用。任一 provider 缺失时,对应 namespace 仍会注册,并返回可操作的配置错误。 +`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入而不返回密钥值,并在 Host 桌面打开由 provider 持有的 settings 或 Agent preset 位置。provider 缺失时,namespace 仍会注册,并返回可操作的配置错误。 ## 目录 - [使用本包](#use-this-package) +- [配置](#configuration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -28,6 +29,19 @@ kind: "package-reference" `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 +`settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.canOpenAgentPresetDirectory()` 在 preset 页面显示时报告原生打开能力。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两个打开方法都不接受浏览器提供的文件系统目标。 + +----- + + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `nativeOpen` | 平台探测 | Agent preset 目录能否交给原生桌面打开器 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-settings-controller)是所有受支持字段及其 JSDoc 的完整来源。 + ----- @@ -43,7 +57,6 @@ kind: "package-reference" -- settings 文档打开使用 API Proxy,而不经过本 Remote namespace。 - 批量上限固定为 64 个引用,不是可按部署配置的字段。 diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index ed518756fb..3f3039aa68 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -49,20 +49,25 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index e893d06649..5fa81d1518 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -7,7 +7,20 @@ * @module @deepseek-ai/dsh-api-settings-controller */ +import { dirname } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' +import { + InvalidPresetIdError, + PresetExistsError, + PresetNotWritableError, + UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' +import { + canOpenNativePath, + openNativePath, + openNativeTextFile, +} from '@deepseek-ai/dsh-native-command' import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings' import type { @@ -17,12 +30,31 @@ import type { JsonValue } from '@deepseek-ai/dsh-session/types' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { CredentialsController } from './credentials.ts' +import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts' export { CredentialsController } from './credentials.ts' export type * from './types.ts' const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) +/** Native document-opening policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} + +/** Read abort state afresh after an awaited provider or opener call. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +/** Host integrations replaceable by direct unit tests. */ +export interface SettingsControllerInternals { + readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise + readonly canOpenPath?: () => boolean +} + /** * Project one redacted descriptor onto its wire view, field by field. The * Gateway returns a business result without decoding it, so a provider whose @@ -59,14 +91,24 @@ declare module '@deepseek-ai/cordis' { * `settings-conflict` or `settings-rejected` with the service's message. */ export class SettingsController extends TypertRemoteService { + static Config: Schema = Schema.object({ nativeOpen: Schema.boolean() }) + + private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly openTextFile: (path: string, signal: AbortSignal) => Promise + private readonly canOpenPath: () => boolean + /** * Register the settings namespace and mount the credentials namespace beside * it. Both namespaces stay registered when a provider is absent so calls can * return the configuration API's actionable missing-provider diagnostic. * @param ctx - Host context where settings and credential providers may be mounted. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) { super(ctx, 'settingsController', { namespace: 'settings' }) + this.openPath = internals.openPath ?? openNativePath + this.openTextFile = internals.openTextFile ?? openNativeTextFile + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(CredentialsController) } @@ -86,6 +128,15 @@ export class SettingsController extends TypertRemoteService { } } + /** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenAgentPresetDirectory(): boolean { + return this.canOpenPath() + } + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. @@ -139,6 +190,81 @@ export class SettingsController extends TypertRemoteService { return this.write(ns, 'mutate', ops, expectedRevision) } + /** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + */ + @Remote + async openSettingsDocument(signal: AbortSignal): Promise { + const settings = this.provider() + if (isAborted(signal)) throw cancelled('settings document open was aborted') + let path: string | undefined + try { + path = await settings.prepareDocument() + } catch (error: unknown) { + if (isAborted(signal)) throw cancelled('settings document preparation was aborted') + throw internal(`settings document preparation failed: ${messageOf(error)}`) + } + if (path === undefined) { + throw internal('settings provider has no local document to open') + } + if (isAborted(signal)) throw cancelled('settings document open was aborted') + try { + await this.openTextFile(path, signal) + return { opened: true } + } catch (error: unknown) { + if (isAborted(signal)) throw cancelled('settings document open was aborted') + throw internal(`path open failed: ${messageOf(error)}`) + } + } + + /** + * Open one user-authored Agent preset directory or return its path when no native opener exists. + * @param agentPreset - preset id resolved against Host-owned roots. + * @param signal - caller lifetime; abort terminates the native command. + * @returns an opened confirmation or the resolved directory for text display. + * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + */ + @Remote + async openAgentPresetDirectory( + agentPreset: string, + signal: AbortSignal, + ): Promise { + if (agentPreset.length === 0) { + throw new TypertRemoteFailure({ + code: 'bad-request', message: 'agent preset id must not be empty', details: {}, + }) + } + const presets = this.ctx.get('agentPresets') + if (presets === undefined) { + throw new TypertRemoteFailure({ + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + }) + } + let directory: string + try { + const preset = await presets.resolve(agentPreset) + if (preset.trust !== 'user') { + throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + } + directory = dirname(preset.path) + } catch (error: unknown) { + throw presetFailure(agentPreset, error) + } + if (!this.canOpenPath()) return { opened: false, path: directory } + try { + await this.openPath(directory, signal) + return { opened: true } + } catch (error: unknown) { + if (signal.aborted) throw cancelled('path open was aborted') + throw internal(`path open failed: ${messageOf(error)}`) + } + } + private async write( ns: string, mode: 'update' | 'replace' | 'mutate', @@ -196,6 +322,44 @@ export class SettingsController extends TypertRemoteService { } } +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function internal(message: string): TypertRemoteFailure { + return new TypertRemoteFailure({ code: 'internal', message, details: {} }) +} + +function cancelled(message: string): TypertRemoteFailure { + return new TypertRemoteFailure({ code: 'cancelled', message, details: {} }) +} + +function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure { + if (error instanceof UnknownPresetError) { + return new TypertRemoteFailure({ + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetNotWritableError) { + return new TypertRemoteFailure({ + code: 'agent-preset-read-only', + message: error.message, + details: { agentPreset, reason: error.message }, + }) + } + if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { + return new TypertRemoteFailure({ + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset, reason: error.message }, + }) + } + if (error instanceof TypertRemoteFailure) return error + return internal(`agent preset "${agentPreset}": ${String(error)}`) +} + /** * Classify one seam refusal. A stale writer is its own outcome, not a malformed * request: the client must re-read and re-apply rather than treat the write as diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 81249e3065..5fde28ae89 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -30,6 +30,16 @@ export type SettingsError = { } }[keyof SettingsErrorDetailsMap] +/** Confirmation that the settings document was handed to the native editor. */ +export interface SettingsDocumentOpenValue { + readonly opened: true +} + +/** Result of opening or revealing one locally authored Agent preset directory. */ +export type AgentPresetDirectoryOpenValue = + | { readonly opened: true } + | { readonly opened: false; readonly path: string } + /** Stable credential failure details returned by the `credentials` namespace. */ export interface CredentialErrorDetailsMap { /** diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index 894fbddd53..7195e6650f 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { + InvalidPresetIdError, + PresetExistsError, + UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' @@ -77,9 +82,12 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(controller.typertRemote.namespace).toBe('settings') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } }, { method: 'update', invocation: { kind: 'direct' } }, { method: 'replace', invocation: { kind: 'direct' } }, { method: 'mutate', invocation: { kind: 'direct' } }, + { method: 'openSettingsDocument', invocation: { kind: 'direct' } }, + { method: 'openAgentPresetDirectory', invocation: { kind: 'direct' } }, ]) }) @@ -91,6 +99,7 @@ describe('the settings Remote namespace a configuration page calls', () => { () => ctx.settingsController.update('ui-test', {}, undefined), () => ctx.settingsController.replace('ui-test', {}, undefined), () => ctx.settingsController.mutate('ui-test', [], undefined), + () => ctx.settingsController.openSettingsDocument(new AbortController().signal), ] for (const call of calls) { const failure = await Promise.resolve().then(call).catch((error: unknown) => error) @@ -239,4 +248,203 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(code).toBe('internal') expect(message).toContain('was disposed after the mutate') }) + + it('prepares and opens the provider-owned settings document', async () => { + const ctx = new Context() + await ctx.plugin(DocumentSettings) + const prepare = vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') + const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const controller = new SettingsController(ctx, {}, { openTextFile }) + const signal = new AbortController().signal + + await expect(controller.openSettingsDocument(signal)).resolves.toEqual({ opened: true }) + expect(prepare).toHaveBeenCalledOnce() + expect(openTextFile).toHaveBeenCalledWith('/tmp/settings.yaml', signal) + }) + + it('preserves settings-document absence, failure, and cancellation', async () => { + const absent = await boot() + const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal) + await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(missingDocument).rejects.toThrow('no local document') + + const failed = await boot(DocumentSettings) + vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed')) + const failedRead = failed.controller.openSettingsDocument(new AbortController().signal) + await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failedRead).rejects.toThrow('read failed') + + const cancelled = new AbortController() + cancelled.abort(new Error('cancelled')) + const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument') + prepare.mockClear() + await expect(failed.controller.openSettingsDocument(cancelled.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + expect(prepare).not.toHaveBeenCalled() + }) + + it('does not open a settings document cancelled during preparation', async () => { + const ctx = new Context() + await ctx.plugin(DocumentSettings) + const prepared = Promise.withResolvers() + vi.spyOn(ctx.settings, 'prepareDocument').mockReturnValue(prepared.promise) + const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const controller = new SettingsController(ctx, {}, { openTextFile }) + const abort = new AbortController() + + const opening = controller.openSettingsDocument(abort.signal) + abort.abort(new Error('cancelled')) + prepared.resolve('/tmp/settings.yaml') + + await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } }) + expect(openTextFile).not.toHaveBeenCalled() + }) + + it('maps native settings-document opener failures', async () => { + const ctx = new Context() + await ctx.plugin(DocumentSettings) + vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') + const controller = new SettingsController(ctx, {}, { + openTextFile: () => Promise.reject(new Error('no default editor')), + }) + + await expect(controller.openSettingsDocument(new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: 'path open failed: no default editor' }, + }) + }) + + it('classifies cancellation while preparing or opening the settings document', async () => { + const preparing = new Context() + await preparing.plugin(DocumentSettings) + const prepareAbort = new AbortController() + vi.spyOn(preparing.settings, 'prepareDocument').mockImplementation(async () => { + prepareAbort.abort(new Error('cancelled')) + throw new Error('preparation stopped') + }) + const preparingController = new SettingsController(preparing) + await expect(preparingController.openSettingsDocument(prepareAbort.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + + const opening = new Context() + await opening.plugin(DocumentSettings) + vi.spyOn(opening.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') + const openAbort = new AbortController() + const openingController = new SettingsController(opening, {}, { + openTextFile: async () => { + openAbort.abort(new Error('cancelled')) + throw new Error('opening stopped') + }, + }) + await expect(openingController.openSettingsDocument(openAbort.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + }) + + it('opens a user Agent preset directory or returns its path without a native opener', async () => { + const ctx = new Context() + ctx.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + expect(openable.canOpenAgentPresetDirectory()).toBe(true) + const signal = new AbortController().signal + await expect(openable.openAgentPresetDirectory('mine', signal)) + .resolves.toEqual({ opened: true }) + expect(openPath).toHaveBeenCalledWith('/presets/mine', signal) + + const headless = new Context() + headless.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const reveal = new SettingsController(headless, { nativeOpen: false }) + expect(reveal.canOpenAgentPresetDirectory()).toBe(false) + await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal)) + .resolves.toEqual({ opened: false, path: '/presets/mine' }) + }) + + it('covers native-open detection defaults and explicit overrides', () => { + const fromInjectedOpener = new SettingsController(new Context(), {}, { + openPath: () => Promise.resolve(), + }) + expect((fromInjectedOpener as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(true) + + const detected = new SettingsController(new Context()) + expect(typeof (detected as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe('boolean') + + const override = vi.fn(() => false) + const overridden = new SettingsController(new Context(), {}, { canOpenPath: override }) + expect((overridden as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(false) + expect(override).toHaveBeenCalledOnce() + }) + + it('refuses a shipped Agent preset and a missing preset provider', async () => { + const ctx = new Context() + ctx.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'system', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const controller = new SettingsController(ctx) + await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } }) + + const missing = new SettingsController(new Context()) + await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } }) + }) + + it('rejects an empty Agent preset id before resolving a provider', async () => { + const resolve = vi.fn() + const ctx = new Context() + ctx.provide('agentPresets', { resolve } as never) + const controller = new SettingsController(ctx) + + await expect(controller.openAgentPresetDirectory('', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + expect(resolve).not.toHaveBeenCalled() + }) + + it.each([ + [new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'], + [new InvalidPresetIdError('../bad'), 'agent-preset-invalid'], + [new PresetExistsError('taken'), 'agent-preset-invalid'], + [new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'], + ['unexpected preset failure', 'internal'], + ] as const)('maps Agent preset resolution failure %#', async (error, code) => { + const ctx = new Context() + ctx.provide('agentPresets', { resolve: async () => { throw error } } as never) + const controller = new SettingsController(ctx) + + await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code } }) + }) + + it('classifies cancellation and non-Error failures from the preset opener', async () => { + const ctx = new Context() + ctx.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const abort = new AbortController() + const openPath = vi.fn() + .mockImplementationOnce(async () => { + abort.abort(new Error('cancelled')) + throw new Error('opening stopped') + }) + .mockRejectedValueOnce('desktop unavailable') + const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + + await expect(controller.openAgentPresetDirectory('first', abort.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + await expect(controller.openAgentPresetDirectory('second', new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, + }) + }) }) diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json index 5b854dbe64..763aa37777 100644 --- a/packages/api/settings-controller/tsconfig.json +++ b/packages/api/settings-controller/tsconfig.json @@ -11,6 +11,12 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../credentials/credentials" }, @@ -20,6 +26,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../util/native-command" + }, { "path": "../../settings/settings" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index bc045f38c7..14586d912e 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index d71cfba44e..bdd01d33ea 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -36,17 +36,15 @@ import type { } from '../src/types.ts' const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } function workspaceClient( remote: WorkspaceRemote, - connection: Pick = AVAILABLE_CONNECTION, + connection: Pick = AVAILABLE_CONNECTION, ) { return { workspace: remote, @@ -199,18 +197,8 @@ async function waitFor(check: () => void): Promise { function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { const connection: ConnectionHandle = { - api: {} as ConnectionHandle['api'], isLoopback: true, - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, - }), - subscribe: () => () => {}, - }, + generation: AVAILABLE_CONNECTION.generation, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 194112be29..550a44efd6 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 1abd03e3e0..d2448330bc 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index d33cae8870..bd03b913cd 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 60c63d4ec4..83c272e666 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 5113761d29..4262b34412 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index ae799992b8..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -165,11 +165,14 @@ writeEveryEvents: 200 writeIntervalMs: 5000 - # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE - # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors - # session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction - # rule, so exports are the raw captured copy. The deployment stance, env - # seams, and follow-ups are pinned in the default-off Agent Note. + # Session telemetry defaults to feedback-gated sharing: FEEDBACK_ONLY + # uploads only when the user records /feedback, releasing the session + # records since the last handoff through that event (a resumed session + # shares only its current lifecycle). DSH_TELEMETRY_MODE overrides to + # FULL or DISABLED; uploading mirrors session-log records onto OTLP/HTTP + # logs with no session-telemetry/record redaction rule, so exports are + # the raw captured copy. The deployment stance, env seams, and follow-ups + # are pinned in the feedback-gated-default Agent Note. # DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the # process out (the launchers patch the row disabled; config cannot disable @@ -187,7 +190,7 @@ - id: session-telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: - mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED' + mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY' shutdownTimeoutMillis: 3000 exporter: url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' @@ -354,7 +357,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 82dcc1e007..daef0bab99 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 00695bca1b..6cbea0c4ce 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -33,7 +33,7 @@ describe('dsh-base bundle', () => { expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({ - __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", + __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'", }) expect(rows.find(row => row.id === 'hmr')).toMatchObject({ disabled: true, diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index fc0be1d54b..0aba9ff549 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/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 packages/bundle/headless/README.md -README.md: 952ae369e58b60389a951f2f48a080d735a3d9ad -README.zh.md: 8d9084550fd295c19b679b64cf17f0e2cdafbeb6 +README.md: 282fa76dd720751d61ba7f44e5e35884d5f21e8f +README.zh.md: 5a2727a25e6e4840900ebc9dc70d10ff13db12dd diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 952ae369e5..282fa76dd7 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -65,7 +65,7 @@ The runner awaits the complete application (`ctx.get('loader')?.await()`) so the ### Patch surface over base -The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona on the base `system-prompt` row, keeps the same temporary process-wide Code Mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts Code Mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. +The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts PTC mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. ### Exit mapping diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 8d9084550f..5a2727a25e 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -65,7 +65,7 @@ runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组 ### 叠加在 base 之上的 patch 表层 -patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona,保留与 Web 表层相同的临时进程级 Code Mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR 行,把 Code Mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。 +patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR 行,把 PTC mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。 ### 退出映射 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 80cc07be60..d1246b79ba 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -11,11 +11,11 @@ - id: tools config: - # Keep the same temporary process-wide Code Mode opt-in as the Web surface. + # Keep the same temporary process-wide PTC mode opt-in as the Web surface. mode: !!js process.env.DSH_TOOLS_MODE - insert: - # Code Mode is a core execution capability, not a Web component. + # PTC mode is a core execution capability, not a Web component. - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker-thread' diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index d8c97032c1..474dc74dd6 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 6ab4167d8d..5e02cda63e 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 42940a0ac8..f94592396c 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 237686c71a..4f438e7d45 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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 packages/bundle/web-app/README.md -README.md: ed2c341c3eb5b3a5ad2b298c4babd0fc19a066e2 -README.zh.md: 1f57a0beff91fc341bef6f9c6e79dab4f1d1b564 +README.md: 5588c7e72e6d61454620075c239ea571d026c5cf +README.zh.md: bfd7a58bf5343ddd06b49b97b622493f389da72f diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index ed2c341c3e..5588c7e72e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ The bundle is one patch plus one runtime glue plugin. The storage stack and proj ### Patch semantics -A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona, the `DSH_TOOLS_MODE` Code Mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. +A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. ### Readiness diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 1f57a0beff..bfd7a58bf5 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080 ### patch 语义 -patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona、`DSH_TOOLS_MODE` Code Mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 +patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 ### 就绪宣告 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 44080b85a2..15a5df12cc 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -30,8 +30,8 @@ - id: tools config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh - # process into Code Mode while per-session tool-presentation selection is being + # TEMPORARY workaround: DSH_TOOLS_MODE (native|ptc|both) opts a whole dsh + # process into PTC mode while per-session tool-presentation selection is being # designed; unset keeps the schema default (native). Remove the env seam # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE @@ -42,7 +42,7 @@ # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: # Host-owned opt-in sampled when a new Web session receives its preset - # delegation tools. The Models page edits this settings namespace. + # delegation tools. The Plugins page edits this settings namespace. - id: subagent-model-selection-settings name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' @@ -95,11 +95,6 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' - # The API gateway: the transport-agnostic dispatch face every client shape - # shares. The base layer's agent-default-model service owns the default model. - - id: api-gateway - name: '@deepseek-ai/dsh-host-apiproxy' - - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index e8722d9ebf..76390ed35c 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -95,7 +95,6 @@ "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 27f1761cea..29a111effe 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -50,7 +50,7 @@ The stack has one-way knowledge, documented in the [Web Client architecture](../ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. -- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). +- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes, and minting stays in Connection ([unary Remote migration](../../.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md)). - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule). diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 75ab67d3de..6be2c4aa21 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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 packages/client/connection/README.md -README.md: 72f8ee150c1879f920f189ad3d0221fce449c451 -README.zh.md: 3ef1e22554f568e5d4fe24556fafbc7ce0413209 +README.md: af757608aa7face6854aaf9d103654f974b51ed4 +README.zh.md: fef7248abe1e19595b90c43a6a1b9abf0aafa88a diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 72f8ee150c..af757608aa 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,5 +1,5 @@ --- -description: "Browser-host wire layer for the web GUI: the shared API client, event-stream delivery with reconnect, the /api HTTP bridge, and the browser-trust fence, for users and maintainers composing or debugging the connection." +description: "Browser-host wire layer for the web GUI: Remote RPC, event-stream delivery with reconnect, exact Fetch routes, the /api HTTP bridge, and the browser-trust fence." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` reconnects with backoff. ## Table of Contents @@ -25,7 +25,7 @@ Protocol and connection-generation layer. The Client plugin mounts `ctx.connecti ## Use this package -The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD` route registry. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. ----- @@ -41,9 +41,9 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It ## Connection generation -API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready' }` item before events. `ConnectionController` waits for that item and `host.describe` in parallel; `onConnected` cannot start baseline reads until both succeed, so baseline acquisition cannot race ahead of incremental observation. +API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. -An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws `hostDescription`, publishes `reconnecting`, and rebuilds the `$events` plus `host.describe` handshake after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws the generation, publishes `reconnecting`, and reopens `$events` after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 3ef1e22554..fef7248abe 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向用户与维护者的浏览器-宿主线层说明:共享 API 客户端、带重连的事件流投递、/api HTTP 桥与浏览器信任栅栏,用于组合或排查连接。" +description: "Web GUI 的浏览器-Host 线层:Remote RPC、带重连的事件流投递、精确 Fetch 路由、/api HTTP 桥与浏览器信任栅栏。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 退避重连。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD` 路由注册表。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 ----- @@ -41,9 +41,9 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi ## Connection generation -API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready' }` 项,再发送事件。`ConnectionController` 并行等待该 ready 与 `host.describe`;只有两者都成功才允许 `onConnected` 启动 baseline 读取,因此 baseline 不会跑在增量 listener 前面。 +API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 -`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 `hostDescription`、发布 `reconnecting`,并在退避后重建 `$events` 与 `host.describe` 握手。Gateway mux 自己负责重建底层 WebSocket;Connection 世代负责重建 logical stream 与 baseline 起点。 +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 generation、发布 `reconnecting`,并在退避后重开 `$events`。Gateway mux 自己负责重建底层 WebSocket;Connection generation 负责重开 logical stream 并建立下一次 baseline 起点。 ## 模型体验 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 2be6924acf..f19f30e40d 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: HTTP client, generation lifecycle, and fixture API", - "version": "0.1.1-rc.2", + "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -38,7 +38,8 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "files": [ "lib/index.js", @@ -51,7 +52,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -65,7 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 4d4826da79..44e7595f34 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -1,46 +1,26 @@ -// Central contract re-export point: every legacy API contract import inside -// the Connection package goes through this browser-safe file. -// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer -// (zero Node deps, browser-safe); AbstractApiClient is the client boundary. -// NEVER import the package root: it drags bootHost/cordis into the browser bundle. -// The ./api and ./client subpath exports are the browser-safe channels. +/** Browser-safe Connection protocol and shared application value exports. */ export type { - ApiProxy, HostApi, - ResponseValue, - SkillsApi, SkillEntry, - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, - SettingsApi, - ConfigurableProviderView, DiscoveredModelView, LlmApi, -} from '@deepseek-ai/dsh-host-apiproxy/api' -export type { - RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, RpcMessage, -} from '@deepseek-ai/dsh-host-apiproxy/api' -// transportError lives in the apiproxy api layer (beside RpcResult, its -// subject); re-exported here so connection consumers keep one contract -// entry point. -export { - RpcId, - transportError, -} from '@deepseek-ai/dsh-host-apiproxy/api' -export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' -export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' + ClientRequest, + RpcError, + RpcErrorCode, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + ServerResponse, +} from '../rpc.ts' +export { RpcId, transportError } from '../rpc.ts' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' export type { MessageId } from '@deepseek-ai/dsh-llm/brand' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' -/** Successful value returned by the connection-generation host handshake. */ -export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'> - -import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcResponse, RpcResult } from '../rpc.ts' /** - * Unwrap a unary response: RpcResponse -> RpcResult (business code only - * cares about the result slot). - * @param response - the unary response. - * @returns its result slot. + * Return the business result carried by a narrow fixture response. + * @param response - fixture response to unwrap. + * @returns the response's business result. */ export function resultOf(response: RpcResponse): RpcResult { return response.result diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index cad2de394d..17e946c80e 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,16 @@ -import type { HostDescription, IApiClient } from './api.ts' +/** Stable Host facts delivered by one established Remote event generation. */ +export interface ConnectionHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string +} + +/** One successfully established Host generation. */ +export interface ConnectionGeneration { + /** Monotone generation number within this Client runtime. */ + readonly id: number + /** Host facts carried by this generation's opening frame. */ + readonly host: ConnectionHostInfo +} /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ @@ -38,8 +50,8 @@ export type ConnectionState = 'connected' | 'reconnecting' /** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { - /** After the generation source is ready and host.describe succeeds, first connect included. */ - onConnected?: (description: HostDescription) => void + /** After the generation source reports ready, first connect included. */ + onConnected?: (host: ConnectionHostInfo) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -55,7 +67,7 @@ export interface ConnectionSinks { */ export type ConnectionGenerationSource = ( signal: AbortSignal, - ready: () => void, + ready: (host: ConnectionHostInfo) => void, ) => Promise /** @@ -72,7 +84,6 @@ export class ConnectionController { private readonly config: Required constructor( - private readonly api: IApiClient, private readonly source: ConnectionGenerationSource, private readonly sinks: ConnectionSinks = {}, config: ConnectionConfig = {}, @@ -117,19 +128,20 @@ export class ConnectionController { this.current = ac let sourceReady = false - let resolveReady!: () => void + let resolveReady!: (host: ConnectionHostInfo) => void let rejectReady!: (error: Error) => void let rejectSourceLost!: (error: Error) => void - const ready = new Promise((resolve, reject) => { + const ready = new Promise((resolve, reject) => { resolveReady = resolve rejectReady = reject }) const sourceLost = new Promise((_resolve, reject) => { rejectSourceLost = reject }) - const reportReady = (): void => { + const reportReady = (host: ConnectionHostInfo): void => { + if (sourceReady) return sourceReady = true - resolveReady() + resolveReady(host) } const failed = new Promise((resolve) => { @@ -158,27 +170,16 @@ export class ConnectionController { }) try { - // The source reports ready only after its incremental listeners exist; - // describe may complete in parallel, but consumers see neither result - // until both sides of the baseline-plus-increment handshake are ready. - const [description] = await Promise.race([ - Promise.all([ - this.api.host.describe({}, ac.signal), - waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), - ]), + const host = await Promise.race([ + waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), sourceLost, ]) - const descriptionResult = description.result - if (!descriptionResult.ok) { - throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) - } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 this.emitState('connected') - // A state sink may synchronously stop this controller. Do not publish - // a description for a generation that no longer exists afterward. + // A state sink may synchronously stop this controller. if (this.isGenerationActive(ac)) { - this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) }) + this.callSink(() => { this.sinks.onConnected?.(host) }) } } catch { // Transport failure: treat as generation failure, fall through to the shared backoff. @@ -213,28 +214,28 @@ export class ConnectionController { } /** Await source readiness without letting a stalled carrier wedge startup forever. */ -function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { +function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { let settled = false const timeout = setTimeout(() => { - finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`)) + finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) }) }, timeoutMs) const aborted = (): void => { - finish(new Error('connection generation aborted', { cause: signal.reason })) + finish({ error: new Error('connection generation aborted', { cause: signal.reason }) }) } - const finish = (error?: Error): void => { + const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => { if (settled) return settled = true clearTimeout(timeout) signal.removeEventListener('abort', aborted) - if (error === undefined) resolve() - else reject(error) + if ('error' in outcome) reject(outcome.error) + else resolve(outcome.value) } signal.addEventListener('abort', aborted, { once: true }) void ready.then( - () => { finish() }, + (value) => { finish({ value }) }, (error: unknown) => { - finish(error as Error) + finish({ error: error as Error }) }, ) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 02eb11fe9c..17b3a006c4 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -33,12 +33,7 @@ import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' -import type { - ApiProxy, ClientRequest, - ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, -} from './api.ts' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' -import { AbstractApiClient, RpcId } from './api.ts' +import type { RpcResult } from './api.ts' import { randomUuid } from './random-uuid.ts' import type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, @@ -46,6 +41,26 @@ import type { const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 +interface ModelSelection { + readonly provider: string + readonly model: string + readonly reasoningEffort?: string +} + +interface ModelProviderGroup { + readonly id: string + readonly name: string + readonly models: readonly { + readonly id: string + readonly name: string + readonly description?: string + readonly reasoning?: { + readonly efforts: readonly { readonly id: string; readonly name: string; readonly description?: string }[] + readonly defaultEffort?: string + } + }[] +} + /* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */ function isFixtureTokenDelta(chunk: StreamChunk): boolean { switch (chunk.type) { @@ -176,6 +191,7 @@ interface FixtureRemoteEventResult { interface FixtureRemoteEventReadyFrame { readonly type: 'ready' readonly clientId: string + readonly host: { readonly home: string } } interface FixtureProjectionFrame { @@ -323,11 +339,6 @@ interface FixtureWorkspace { updatedAt: string } -/** The fake carrier mints like a real one (business code never mints). */ -function rpcRequest

(payload: P): RpcRequest

{ - return { rpcId: RpcId(randomUuid()), payload } -} - function text(t: string): ContentBlock[] { return [{ type: 'text', text: t }] } @@ -551,7 +562,7 @@ const OPENAI_REASONING = { defaultEffort: 'medium', } -/** Catalog served by `llm.models` (fresh copies per call). */ +/** Catalog served by `session/modelCatalog` (fresh copies per call). */ function fixtureModelGroups(): ModelProviderGroup[] { return [ { @@ -1730,34 +1741,22 @@ class FxInbox implements StreamConn { } } -/** - * In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material). - * @param options - fixture branches for empty state and failure timing. - * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. - */ -export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { - return createFixtureWorld(options).api -} - -/** Both fixture faces over one state graph. */ +/** Fixture RPC face over one in-memory state graph. */ export interface FixtureWorld { - /** Legacy unary/stream API the fixture still answers. */ - readonly api: ApiProxy /** Generic Remote caller for the endpoints business services own. */ readonly rpc: ClientConnectionRpc } /** - * Build both fixture faces so a caller can drive the Remote endpoints and the - * legacy API against one in-memory state graph. + * Build the fixture RPC face over one in-memory state graph. * @param options - fixture branches for empty state and failure timing. - * @returns the legacy API face and the Remote RPC face. + * @returns the Remote RPC face. */ export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { return createFixtureWorld(options) } -/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ +/** Build the fixture's Remote RPC face over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. const sessions: FixtureSessionSummary[] = options.empty ? [] : [ @@ -1834,6 +1833,25 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } }, + openSettingsDocument(): RpcResult<{ opened: true }> { + return { ok: true, value: { opened: true } } + }, + openAgentPresetDirectory(agentPreset: string): RpcResult< + { opened: true } | { opened: false; path: string } + > { + const existing = fixturePresets.get(agentPreset) + if (existing === undefined || existing.trust === 'system') { + return { + ok: false, + error: { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }, + } + } + return { ok: true, value: { opened: true } } + }, } const credentialRemotes = { @@ -1870,7 +1888,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 - let attachedSessions = options.empty ? 0 : 1 // Workspace entities mirroring the host registry: the fixture sessions all // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId @@ -1995,14 +2012,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { for (const conn of followConns.get(sessionId) ?? []) conn.push(entry) } - /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */ - function ok(request: RpcRequest

, value: T): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) - } - function err(request: RpcRequest

, error: Extract, { ok: false }>['error']): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } }) - } - function sessionOk(value: T): Promise> { return Promise.resolve({ ok: true, value }) } @@ -2012,16 +2021,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const summaryOf = (id: SessionId): FixtureSessionSummary | 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 requireRemoteSession = ( request: { readonly sessionId: SessionId }, ): Promise> | undefined => { @@ -2811,7 +2810,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } sessions.push(created) modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - attachedSessions += 1 const emitSession = (): void => { emitRemote('api-session/added', [created]) } @@ -2962,9 +2960,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) return { type: 'image', attachment } }) + // The host echoes the prompt's requestId as the user source's rpcId; + // the Session object retires its local submission echo on it. The + // user-rpc source member is declared by dsh-api-session-controller, + // which this standalone fixture does not import — hence the assertion. + const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource if (mode === 'steer' && replays.has(id)) { // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) return sessionOk({ accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 @@ -2977,7 +2980,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (plan.wanted !== null && plan.wanted !== plan.active) { append(id, { type: 'plan/mode', data: { active: plan.wanted } }) } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) // Capacity parallel of the host token-meter's request/context record: // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). @@ -3151,7 +3154,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) }, 5000) try { - yield { type: 'ready', clientId } + yield { type: 'ready', clientId, host: { home: FIXTURE_HOME } } if (approvalPending) yield approvalInvocation() if (questionPending) yield questionInvocation() yield* conn.drain(signal) @@ -3388,79 +3391,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } - const api: ApiProxy = { - host: { - describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, - }), - openPath: request => ok(request, { opened: true as const }), - }, - agentPresets: { - // Native opens are deterministic no-op successes in this fixture, so the - // open-directory affordance renders and the path-text fallback stays a - // component-test concern. - openDocument: (request) => { - const { agentPreset } = request.payload - const existing = fixturePresets.get(agentPreset) - if (existing === undefined || existing.trust === 'system') { - return err(request, { - code: 'agent-preset-read-only', - message: `agent preset "${agentPreset}" ships with the deployment`, - details: { agentPreset, reason: 'it ships with the deployment' }, - }) - } - return ok(request, { opened: true 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 目录渲染验收', modelInvocable: true }, - { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, - ], - }) - }, - }, - settings: { - // Native opens are deterministic no-op successes in this fixture, as is host.openPath. - openDocument: request => ok(request, { opened: true as const }), - }, - llm: { - providers: request => ok(request, { - providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false }, - // One hand-declared route, so a surface reading this fixture meets - // the tagged shape rather than only the shipped one. - { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true }, - ], - }), - models: request => ok(request, { - default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], - groups: fixtureModelGroups(), - failures: [], - }), - // The fixture endpoint is imaginary, so the interrogation answers the - // catalog it already serves — enough for a surface to exercise adopting - // candidates without a reachable provider. - discoverModels: request => ok(request, { - models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), - }), - }, - // Satisfies the ApiProxy contract type only: the browser export button - // hands GET /api/session.export to the native download manager, so this - // stub is never reached through the fixture's dispatch. - downloads: { - sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), - }, - } - const rpc: ClientConnectionRpc = { call(channel, endpoint, payload, signal) { if (channel !== '/api') { @@ -3479,6 +3409,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { refs?: readonly string[] value?: string ns?: string + settingsNs?: string agentPreset?: string from?: string id?: string @@ -3533,6 +3464,58 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string)) case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string)) case 'settings/describe': return Promise.resolve(settingsRemotes.describe()) + case 'settings/canOpenAgentPresetDirectory': return Promise.resolve({ ok: true, value: true }) + case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument()) + case 'settings/openAgentPresetDirectory': return Promise.resolve( + settingsRemotes.openAgentPresetDirectory(args.agentPreset as string), + ) + case 'skills/list': { + const skillRequest = request as { readonly sessionId: SessionId } + const missing = requireRemoteSession(skillRequest) + if (missing !== undefined) return missing + return sessionOk({ + skills: [ + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, + ], + }) + } + case 'session/openWorkspacePath': { + return sessionOk({ opened: true as const }) + } + case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true }) + case 'session/modelCatalog': return Promise.resolve({ + ok: true, + value: { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], + groups: fixtureModelGroups(), + failures: [], + }, + }) + case 'llm/listProviders': return Promise.resolve({ + ok: true, + value: [ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'openai', name: 'openai' }, + { id: 'acme-gateway', name: 'Acme Gateway' }, + ], + }) + case 'llm/listConfigurableProviders': return Promise.resolve({ + ok: true, + value: [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], declared: false }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], declared: false }, + { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], declared: true }, + ], + }) + // The fixture endpoint is imaginary, so interrogation answers the + // catalog it already serves without a network request. + case 'llm/discoverModels': return Promise.resolve({ + ok: true, + value: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), + }) case 'settings/update': return Promise.resolve(settingsRemotes.update(args.ns as string)) case 'settings/replace': return Promise.resolve(settingsRemotes.replace(args.ns as string)) case 'settings/mutate': return Promise.resolve(settingsRemotes.mutate(args.ns as string)) @@ -3607,68 +3590,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } }, } - return { api, rpc } + return { rpc } } /** - * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it - * overrides the legacy protocol-level call virtual to dispatch - * straight into the in-memory ApiProxy while still minting rpcIds, fabricating - * the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture - * moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). + * Build the browser fixture transport from the current page's query switches. + * @returns an in-memory Connection RPC transport. */ -export class FixtureApiClient extends AbstractApiClient { - private readonly api: ApiProxy - /** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */ - readonly rpc: ClientConnectionRpc - - constructor() { - super() - const world = createFixtureWorld(fixtureOptionsFromLocation()) - this.api = world.api - this.rpc = world.rpc - } - - protected doFetch(): Promise { - throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable') - } - - protected override async callUnary( - method: K, - payload: RequestPayload, - signal?: AbortSignal, - ): Promise>> { - const request = rpcRequest(payload) - const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload } - this.onEnvelope(full) - const response = await this.dispatch( - method, - request as RpcRequest, - signal ?? new AbortController().signal, - ) as RpcResponse> - const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result } - this.onEnvelope(fullResponse) - return response - } - - /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */ - private dispatch( - method: keyof RpcMethodMap, - request: RpcRequest, - signal: AbortSignal, - ): Promise> { - switch (method) { - case 'host.describe': return this.api.host.describe(request) - case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) - case 'skill.list': return this.api.skills.list(request) - case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) - case 'settings.openDocument': return this.api.settings.openDocument(request, signal) - case 'llm.providers': return this.api.llm.providers(request) - case 'llm.models': return this.api.llm.models(request) - case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal) - } - } - +export function createFixtureConnectionRpc(): ClientConnectionRpc { + return createFixtureWorld(fixtureOptionsFromLocation()).rpc } /** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c8b66fbc77..36b9025d64 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -3,16 +3,14 @@ * the shared API client, and lets API Gateway own the connection loop. */ import type { Context } from '@deepseek-ai/cordis' -import type { HostDescription, IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, + type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, - type ConnectionState, } from './connection.ts' -import { FixtureApiClient } from './fixture.ts' -import { WebApiClient } from './web-api-client.ts' +import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -28,37 +26,38 @@ declare module '@deepseek-ai/cordis' { } } -// ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- +// ---- Browser-safe protocol and shared value re-exports ---- export type { - ApiProxy, HostApi, - SkillsApi, SkillEntry, - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelSelection, + MessageId, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, - HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, - SettingsApi, - ConfigurableProviderView, DiscoveredModelView, LlmApi, + SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' export { RpcId, - AbstractApiClient, transportError, } from './api.ts' // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. -export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState } +export type { + ConnectionConfig, + ConnectionGeneration, + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionSinks, + ConnectionState, +} from './connection.ts' export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' -/** Observable Host description published by each completed connection handshake. */ -export interface HostDescriptionSource { - /** Latest connected-generation description; absent before connect and while reconnecting. */ - getSnapshot(): HostDescription | undefined - /** Subscribe to description replacement and connection loss. */ +/** Observable identity and Host facts for the active connection generation. */ +export interface ConnectionGenerationState { + /** Active generation, or undefined before readiness and while reconnecting. */ + getSnapshot(): ConnectionGeneration | undefined + /** Subscribe to generation establishment, replacement, and loss. */ subscribe(listener: () => void): () => void } @@ -72,8 +71,6 @@ export const inject: string[] = [] * provides both halves here instead of forking this plugin. */ export interface ClientTransportHooks { - /** Build the API carrier: unary calls plus the two downstream event streams. */ - createApiClient(): IApiClient /** Transport for generic unary RPC channels (the Typert gateway). */ fetch: RpcFetch /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ @@ -106,16 +103,14 @@ interface ClientTransportGlobal { * Connection stays independent of downstream domain state. */ export interface ConnectionHandle { - /** Shared api client (fixture or real, decided at boot from the page URL). */ - readonly api: IApiClient /** * Whether the privileged surface is reachable: the page authority is * loopback, the transport declares the page owns the Host * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including the account home and native path-open capability. */ - readonly hostDescription: HostDescriptionSource + /** Current Remote event generation and the Host facts carried by its opening frame. */ + readonly generation: ConnectionGenerationState /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc /** @@ -148,22 +143,22 @@ interface ConnectionOwner { export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const fixtureClient = fixture ? new FixtureApiClient() : undefined + const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ - const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient() - const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined - let description: HostDescription | undefined - const descriptionListeners = new Set<() => void>() - const publishDescription = (next: HostDescription | undefined): void => { - if (Object.is(description, next)) return - description = next - for (const listener of [...descriptionListeners]) { + let generationId = 0 + let generation: ConnectionGeneration | undefined + const generationListeners = new Set<() => void>() + const publishGeneration = (next: ConnectionGeneration | undefined): void => { + if (Object.is(generation, next)) return + generation = next + for (const listener of [...generationListeners]) { try { listener() } catch (error) { - console.error('[connection] host-description listener threw:', error) + console.error('[connection] generation listener threw:', error) } } } @@ -171,16 +166,15 @@ export function apply(ctx: Context): void { if (owner !== current) return owner = undefined current.controller.stop() - publishDescription(undefined) + publishGeneration(undefined) } const handle: ConnectionHandle = { - api, isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), - hostDescription: { - getSnapshot: () => description, + generation: { + getSnapshot: () => generation, subscribe: (listener) => { - descriptionListeners.add(listener) - return () => { descriptionListeners.delete(listener) } + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } }, }, rpc, @@ -202,19 +196,18 @@ export function apply(ctx: Context): void { if (source === undefined) throw new Error('connection: no generation source is registered') const token = {} const ownsGeneration = (): boolean => owner?.token === token - const controller = new ConnectionController(api, source, { + const controller = new ConnectionController(source, { ...sinks, - onConnected: (next) => { - publishDescription(next) - // A description subscriber may synchronously stop the loop. In that - // case publishDescription(undefined) has already retracted this - // generation, so do not leak its stale connected notification to - // the consumer sink afterward. - if (!ownsGeneration() || !Object.is(description, next)) return - sinks.onConnected?.(next) + onConnected: (host) => { + const nextGeneration = { id: ++generationId, host } + publishGeneration(nextGeneration) + if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return + sinks.onConnected?.(host) }, onStateChange: (state) => { - if (state === 'reconnecting') publishDescription(undefined) + if (state === 'reconnecting') { + publishGeneration(undefined) + } if (!ownsGeneration()) return sinks.onStateChange?.(state) }, diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index c7b609c01b..2c3f7d3e28 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -4,7 +4,7 @@ import { RpcId, type ClientRequest, type RpcId as RpcIdType, -} from '@deepseek-ai/dsh-host-apiproxy/api' +} from '../rpc.ts' import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts' import { randomUuid } from './random-uuid.ts' diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts deleted file mode 100644 index 6716f252a5..0000000000 --- a/packages/client/connection/src/client/web-api-client.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Browser API carrier for unary HTTP calls. */ - -import { AbstractApiClient } from './api.ts' - -/** Browser platform subclass supplying fetch for unary calls. */ -export class WebApiClient extends AbstractApiClient { - protected doFetch(input: URL, init?: RequestInit): Promise { - return globalThis.fetch(input, init) - } -} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c7203f38d8..34cf79bd65 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-credentials' // Activates the webServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' import { assertTrustedAuthority } from './api-request-trust.ts' @@ -13,6 +12,9 @@ import { BrowserAuth } from './browser-auth.ts' import { HostConnectionService } from './rpc-host.ts' export type { + ConnectionFetchMethod, + ConnectionFetchHandler, + ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, @@ -21,9 +23,22 @@ export type { ConnectionRequestRejection, ConnectionRpcResult, ConnectionTrustRequest, + ClientRequest, HostConnectionHandle, + HostConnectionFetch, HostConnectionRpc, + RpcMessage, + ServerResponse, } from './rpc.ts' +export { RpcId, transportError } from './rpc.ts' +export { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from './rpc-schema.ts' export { HostConnectionService } from './rpc-host.ts' export { API_PATH } from './api-path.ts' @@ -48,7 +63,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } } -/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ +/** Services required before providing Connection. */ export const inject = ['webServer', 'credentials'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -89,19 +104,13 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise ctx.webServer.register(route), 'client-connection: /api route') - ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) }) + ctx.inject(['attachments'], (attachmentCtx) => { + assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes) + }) } diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 5a187545b5..3b96a053eb 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -18,8 +18,8 @@ export const inject = ['invariants'] * No runtime invariant: browser-session verification reads the credential * record asynchronously at the request that authorizes work, while the * credentials companion owns record commit-event lifetime. Stream/reconnect - * sequencing is exercised directly by behavior specs, rpcId round-trip - * discipline belongs to apiproxy, and route register/dispose symmetry is + * sequencing and rpcId round-trip discipline are exercised directly by + * behavior specs, and route register/dispose symmetry is * audited by the webserver companion. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 4f1e78341b..a00277d813 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -3,13 +3,11 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { - clientRequestSchema, RpcId, type ClientRequest, - type RpcError, - type RpcErrorDetailsMap, type RpcId as RpcIdType, -} from '@deepseek-ai/dsh-host-apiproxy/api' +} from './rpc.ts' +import { clientRequestSchema } from './rpc-schema.ts' import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' @@ -17,7 +15,11 @@ import type { BrowserAuth } from './browser-auth.ts' import type { ConnectionIndexRequest, ConnectionIndexResponse, + ConnectionFetchRoute, + ConnectionFetchHandler, + HostConnectionFetch, ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcResult, ConnectionRequestRejection, @@ -35,6 +37,11 @@ interface ConnectionRpcInterceptor { readonly fetchHandler: FetchHandler } +interface RegisteredFetchRoute { + readonly methods: ReadonlySet + readonly fetch: ConnectionFetchRoute['fetch'] +} + interface ConnectionServerResponse { readonly type: 'server-response' readonly rpcId: RpcIdType @@ -51,6 +58,7 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { private readonly interceptors = new Map() + private readonly fetchRoutes = new Map() /** * Provide the Host half over the active HTTP server. @@ -76,6 +84,14 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + /** Exact Fetch-route registry scoped to the Context reading this service. */ + get fetch(): HostConnectionFetch { + const owner = this.ctx + return { + register: route => this.registerFetchRoute(owner, route), + } + } + /** Apply the configured Host/Origin fence, then browser authentication. */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 @@ -93,27 +109,46 @@ export class HostConnectionService extends Service implements HostConnectionHand } /** - * Compose one shared-channel Fetch handler from its interceptor and fallback. + * Compose one shared-channel Fetch handler from exact routes and its interceptor. * @param channel - shared channel mounted by Connection. - * @param fallback - handler for endpoints not claimed by the interceptor. - * @returns Fetch handler that selects exactly one target for each request. + * @returns Fetch handler that selects one owner or returns 404. */ createSharedFetchHandler( channel: '/api', - fallback: FetchHandler, - ): FetchHandler { + ): ConnectionFetchHandler { return { fetch: (request) => { - const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const pathname = new URL(request.url).pathname + const route = this.fetchRoutes.get(pathname) + if (route?.methods.has(request.method) === true) return route.fetch(request) + const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { - return fallback.fetch(request) + return Promise.resolve(new Response('not found', { status: 404 })) } return interceptor.fetchHandler.fetch(request) }, } } + private registerFetchRoute( + owner: Context, + route: ConnectionFetchRoute, + ): () => Promise { + assertFetchRoute(route) + const registered: RegisteredFetchRoute = { + methods: new Set(route.methods), + fetch: route.fetch, + } + return owner.effect(() => { + if (this.fetchRoutes.has(route.path)) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`) + } + this.fetchRoutes.set(route.path, registered) + return () => { this.fetchRoutes.delete(route.path) } + }, `client-connection: ${route.path} Fetch route`) + } + private register( owner: Context, channel: string, @@ -211,7 +246,7 @@ function rpcFetchHandler( } } -function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { +function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response { const rawId = (body as { rpcId?: unknown } | null)?.rpcId const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID return errorResponse(rpcId, { @@ -232,7 +267,7 @@ function endpointFromPath(channel: string, pathname: string): string | undefined return endpoint } -function errorResponse(rpcId: RpcIdType, error: RpcError): Response { +function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response { return fullResponse(rpcId, { ok: false, error }) } @@ -246,3 +281,16 @@ function assertChannel(channel: string): void { throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) } } + +function assertFetchRoute(route: ConnectionFetchRoute): void { + if (endpointFromPath(API_PATH, route.path) === undefined) { + throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`) + } + if (route.methods.length === 0) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`) + } + const methods = new Set(route.methods) + if (methods.size !== route.methods.length) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`) + } +} diff --git a/packages/client/connection/src/rpc-schema.ts b/packages/client/connection/src/rpc-schema.ts new file mode 100644 index 0000000000..dc919a8187 --- /dev/null +++ b/packages/client/connection/src/rpc-schema.ts @@ -0,0 +1,53 @@ +/** Runtime validation for Connection RPC envelopes. */ + +import { z } from 'zod' +import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from './rpc.ts' + +/** Correlation id after wire validation. */ +export const rpcIdSchema = z.string() as unknown as z.ZodType + +/** Generic endpoint failure carried in a response envelope. */ +export const rpcErrorSchema = z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()), +}) + +/** + * Build the result parser for one endpoint value parser. + * @param value - endpoint-owned success-value parser. + * @returns parser for either a success value or generic failure. + */ +export function rpcResultSchema(value: z.ZodType): z.ZodType<{ + readonly ok: true + readonly value: T +} | { + readonly ok: false + readonly error: z.infer +}> { + return z.union([ + z.object({ ok: z.literal(true), value }), + z.object({ ok: z.literal(false), error: rpcErrorSchema }), + ]) +} + +/** Client request envelope; endpoint payload validation belongs to its owner. */ +export const clientRequestSchema = z.object({ + type: z.literal('client-request'), + rpcId: rpcIdSchema, + method: z.string(), + payload: z.unknown(), +}) as z.ZodType + +/** Server response envelope; endpoint value validation belongs to its caller. */ +export const serverResponseSchema = z.object({ + type: z.literal('server-response'), + rpcId: rpcIdSchema, + result: rpcResultSchema(z.unknown().optional()), +}) as z.ZodType + +/** Either Connection RPC envelope direction. */ +export const rpcMessageSchema = z.discriminatedUnion('type', [ + clientRequestSchema as unknown as z.ZodObject, + serverResponseSchema as unknown as z.ZodObject, +]) as unknown as z.ZodType diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 1f879963af..6cbe86d837 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,5 +1,20 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Correlation id minted by a caller and echoed by the Connection response. */ +export type RpcId = Branded<'rpc-id'> + +/** + * Brand one validated string as a Connection correlation id. + * @param id - validated wire identity. + * @returns the same string with the correlation-id brand. + */ +export function RpcId(id: string): RpcId { + return id as RpcId +} + /** Carrier-neutral failure returned by one logical RPC endpoint. */ export interface ConnectionRpcFailure { readonly code: string @@ -12,6 +27,81 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } +/** Typed failure details used by Client Session adapters. */ +export interface RpcErrorDetailsMap { + 'bad-request': { issues: object[] } + 'cancelled': {} + 'session-not-found': { sessionId: SessionId } + 'invalid-time-zone': { value: string } + 'agent-preset-read-only': { agentPreset: string; reason: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } + 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } + 'agent-busy': { reason: string } + 'internal': {} +} + +/** Error codes used by Client Session adapters. */ +export type RpcErrorCode = keyof RpcErrorDetailsMap + +/** Typed failure used by Client Session adapters. */ +export type RpcError = { + [Code in RpcErrorCode]: { + readonly code: Code + readonly message: string + readonly details: RpcErrorDetailsMap[Code] + } +}[RpcErrorCode] + +/** Historical short name for a generic Connection result. */ +export type RpcResult = ConnectionRpcResult + +/** + * Convert a rejected transport operation into a generic failure result. + * @param error - rejected transport value. + * @returns an `internal` failure preserving the available message. + */ +export function transportError(error: unknown): RpcResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} + +/** Narrow request form used by direct fixture adapters. */ +export interface RpcRequest

{ + readonly rpcId: RpcId + readonly payload: P +} + +/** Narrow response form used by direct fixture adapters. */ +export interface RpcResponse { + readonly rpcId: RpcId + readonly result: RpcResult +} + +/** Full request envelope carried by Connection RPC transports. */ +export interface ClientRequest { + readonly type: 'client-request' + readonly rpcId: RpcId + readonly method: string + readonly payload: unknown +} + +/** Full response envelope carried by Connection RPC transports. */ +export interface ServerResponse { + readonly type: 'server-response' + readonly rpcId: RpcId + readonly result: ConnectionRpcResult +} + +/** Complete Connection RPC envelope union. */ +export type RpcMessage = ClientRequest | ServerResponse + /** HTTP request facts consumed by browser trust and authentication. */ export interface ConnectionTrustRequest { /** Request headers supplied by either the Fetch or node:http representation. */ @@ -43,6 +133,29 @@ export type ConnectionRpcHandler = ( /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean +/** HTTP methods supported by exact Fetch routes on the shared API channel. */ +export type ConnectionFetchMethod = 'GET' | 'HEAD' + +/** One exact, transport-independent Fetch route owned by a Host feature. */ +export interface ConnectionFetchRoute { + /** Absolute path below `/api`; query parameters remain available on the request URL. */ + readonly path: string + /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ + readonly methods: readonly ConnectionFetchMethod[] + /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + readonly fetch: (request: Request) => Promise +} + +/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */ +export interface HostConnectionFetch { + /** + * Register one exact route on the shared API channel. + * @param route - path, methods, and Fetch-shaped implementation. + * @returns asynchronous disposer removing this exact contribution. + */ + register(route: ConnectionFetchRoute): () => Promise +} + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** @@ -74,6 +187,15 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + /** Exact Fetch routes for streaming or browser-native responses. */ + readonly fetch: HostConnectionFetch + + /** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ + createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler /** * Apply Connection's Host/Origin checks and browser authentication to @@ -99,6 +221,16 @@ export interface HostConnectionHandle { authenticatedUrl(baseUrl: string): string } +/** Transport-independent Fetch handler used by HTTP and worker carriers. */ +export interface ConnectionFetchHandler { + /** + * Dispatch one already-authenticated request. + * @param request - Fetch request below the shared channel. + * @returns the registered response or a 404 response. + */ + fetch(request: Request): Promise +} + /** Client caller for logical RPC channels carried by the current transport. */ export interface ClientConnectionRpc { /** diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 443d398eba..e317c78f0d 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -10,8 +10,6 @@ import { type ConnectionGenerationSource, type ConnectionHandle, } from '../src/client/index.ts' -import { FixtureApiClient } from '../src/client/fixture.ts' -import { WebApiClient } from '../src/client/web-api-client.ts' type Win = { location?: { hostname: string; search: string; origin?: string } @@ -37,7 +35,7 @@ class GenerationProbe { } this.active.add(finish) signal.addEventListener('abort', finish, { once: true }) - ready() + ready({ home: '/h' }) if (signal.aborted) finish() }) @@ -61,20 +59,22 @@ async function mount(): Promise { } describe('connection client apply', () => { - it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { + it('treats a runtime without browser location as local', async () => { + delete (globalThis as Win).location + expect((await mount()).isLoopback).toBe(true) + }) + + it('mounts ctx.connection and identifies a loopback page', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() - expect(handle.api).toBeInstanceOf(WebApiClient) expect(handle.isLoopback).toBe(true) }) - it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { + it('selects the fixture RPC transport under ?fixture', async () => { ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } - expect((await mount()).api).toBeInstanceOf(FixtureApiClient) - delete (globalThis as Win).location const handle = await mount() - expect(handle.api).toBeInstanceOf(WebApiClient) - expect(handle.isLoopback).toBe(true) + await expect(handle.rpc.call('/api', 'settings/describe', { args: {} })) + .resolves.toMatchObject({ ok: true }) }) it('reports non-loopback page authority through the connection handle', async () => { @@ -98,10 +98,10 @@ describe('connection client apply', () => { const loop = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) unregisterSecond() - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() loop.stop() }) @@ -110,26 +110,26 @@ describe('connection client apply', () => { const handle = await mount() installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const descriptions: Array = [] - const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') }) - const stopDescription = handle.hostDescription.subscribe(() => { - descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + const generations: Array = [] + const stopThrowing = handle.generation.subscribe(() => { throw new Error('subscriber bug') }) + const stopGeneration = handle.generation.subscribe(() => { + generations.push(handle.generation.getSnapshot()?.host.home) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() // config omitted: the `config ?? {}` default arm is part of the surface. let connected = 0 const loop = handle.start({ onConnected: () => { connected++ } }) expect(() => handle.start({})).toThrow(/already owned by another consumer/) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) loop.stop() // teardown must not throw; the fixture streams abort quietly - expect(handle.hostDescription.getSnapshot()).toBeUndefined() - expect(descriptions).toEqual([true, undefined]) + expect(handle.generation.getSnapshot()).toBeUndefined() + expect(generations).toEqual(['/h', undefined]) expect(connected).toBe(1) expect(errorSpy).toHaveBeenCalledTimes(2) stopThrowing() - stopDescription() + stopGeneration() errorSpy.mockRestore() }) @@ -140,87 +140,87 @@ describe('connection client apply', () => { const first = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) first.stop() - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() const second = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) first.stop() - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') second.stop() generation.end() }) - it('does not announce a generation synchronously stopped by a description subscriber', async () => { + it('does not announce a generation synchronously stopped by a generation subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() installGeneration(handle) const owner: { loop?: ReturnType } = {} - let sawDescription = false - const stopDescription = handle.hostDescription.subscribe(() => { - if (handle.hostDescription.getSnapshot() === undefined) return - sawDescription = true + let sawGeneration = false + const stopGeneration = handle.generation.subscribe(() => { + if (handle.generation.getSnapshot() === undefined) return + sawGeneration = true owner.loop?.stop() }) const connected = vi.fn() const loop = handle.start({ onConnected: connected }) owner.loop = loop try { - await vi.waitFor(() => { expect(sawDescription).toBe(true) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + await vi.waitFor(() => { expect(sawGeneration).toBe(true) }) + expect(handle.generation.getSnapshot()).toBeUndefined() expect(connected).not.toHaveBeenCalled() } finally { - stopDescription() + stopGeneration() loop.stop() } }) - it('retracts the host description while reconnecting and republishes the next generation', async () => { + it('retracts the generation while reconnecting and publishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) - const descriptions: Array = [] - const reconnectSnapshots: Array = [] - const stopDescription = handle.hostDescription.subscribe(() => { - descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + const generations: Array = [] + const reconnectSnapshots: Array = [] + const stopGeneration = handle.generation.subscribe(() => { + generations.push(handle.generation.getSnapshot()?.host.home) }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { if (state === 'reconnecting') { - reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) + reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home) } }, }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) generation.end() await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) - await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + await vi.waitFor(() => { expect(generations).toEqual(['/h', undefined, '/h']) }) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') } finally { - stopDescription() + stopGeneration() loop.stop() warnSpy.mockRestore() } }) - it('does not announce reconnecting after a description subscriber stops the loop', async () => { + it('does not announce reconnecting after a generation subscriber stops the loop', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) const owner: { loop?: ReturnType } = {} let stoppedOnRetraction = false - const stopDescription = handle.hostDescription.subscribe(() => { - if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return + const stopGeneration = handle.generation.subscribe(() => { + if (handle.generation.getSnapshot() !== undefined || owner.loop === undefined) return stoppedOnRetraction = true owner.loop.stop() }) @@ -232,38 +232,20 @@ describe('connection client apply', () => { owner.loop = loop try { await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) generation.end() await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() expect(states).toEqual(['connected']) } finally { - stopDescription() + stopGeneration() loop.stop() warnSpy.mockRestore() } }) - it('WebApiClient keeps unary calls on globalThis.fetch', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '' } - const handle = await mount() - const original = globalThis.fetch - const seen: string[] = [] - globalThis.fetch = (input: URL | RequestInfo) => { - seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) - return Promise.resolve(new Response('{}', { status: 200 })) - } - try { - // Schema rejection is fine — the transport hop is the assertion. - await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) - } finally { - globalThis.fetch = original - } - expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) - }) - it('carries RPC calls without requiring secure-context randomUUID', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } vi.stubGlobal('crypto', { @@ -311,7 +293,6 @@ describe('connection client apply', () => { })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { - createApiClient: () => new FixtureApiClient(), fetch: vi.fn(), openStream, ownsHost: true, @@ -428,7 +409,7 @@ describe('connection client apply', () => { } }) - it('carries Goal Remotes over the same state as the client-only fixture API', async () => { + it('carries Goal Remotes over the client-only fixture state', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const created = await handle.rpc.call('/api', 'goals/create', { diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 03f57f9273..94038abf8e 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -1,117 +1,52 @@ -/** - * ConnectionController: strict readiness handshake (describe + incremental - * source ready), generation - * abort on loss, backoff reconnection, state transitions, and sink-exception - * isolation. Real (short) timers — the timeout and backoff are configurable, - * so tests run them at millisecond scale. - */ +/** Connection generation readiness, loss, retry, and sink isolation. */ import { describe, expect, it, vi } from 'vitest' -import type { ConnectionState } from '../src/client/connection.ts' +import type { ConnectionGenerationSource, ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' -import { FakeApiClient, deferred, ok } from './fake-api.client.ts' +import { FakeGenerationSource } from './fake-generation.client.ts' const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { - it('announces connected after describe plus generation readiness', async () => { - const api = new FakeApiClient() - const descriptions: boolean[] = [] - let connected = 0 - const controller = new ConnectionController(api, api.generation, { - onConnected: (description) => { - connected++ - descriptions.push(description.canOpenPath) - }, + it('announces connected with the Host facts from generation readiness', async () => { + const source = new FakeGenerationSource() + const homes: string[] = [] + const controller = new ConnectionController(source.source, { + onConnected: (host) => { homes.push(host.home) }, }, FAST) controller.start() try { - await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.callsOf('host.describe')).toHaveLength(1) - expect(descriptions).toEqual([true]) + await vi.waitFor(() => { expect(homes).toEqual(['/h']) }) } finally { controller.stop() } }) it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.failStreams(new Error('stream torn')) - await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff - expect(api.openGenerationCount).toBe(1) + source.fail(new Error('stream torn')) + await vi.waitFor(() => { expect(connected).toBe(2) }) + expect(source.activeCount).toBe(1) } finally { controller.stop() warnSpy.mockRestore() } - // stop() aborts the live generation and no reconnect follows. - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) await new Promise(resolve => setTimeout(resolve, 40)) - expect(api.openGenerationCount).toBe(0) - }) - - it('treats describe failure as generation failure and retries', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise - } - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff - expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - await vi.waitFor(() => { expect(connected).toBe(1) }) - } finally { - controller.stop() - warnSpy.mockRestore() - } - }) - - it('treats a host.describe business error as generation failure', async () => { - const api = new FakeApiClient() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls += 1 - if (describeCalls === 1) { - return Promise.resolve({ - rpcId: 'bad-describe' as never, - result: { - ok: false as const, - error: { code: 'internal' as const, message: 'not ready', details: {} }, - }, - }) - } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - } - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) - await vi.waitFor(() => { expect(connected).toBe(1) }) - } finally { - controller.stop() - warnSpy.mockRestore() - } + expect(source.activeCount).toBe(0) }) it('isolates a connected sink exception from the generation', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() let connected = 0 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ throw new Error('business layer bug') @@ -120,7 +55,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openGenerationCount).toBe(1) + expect(source.activeCount).toBe(1) expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error)) } finally { controller.stop() @@ -128,47 +63,75 @@ describe('connection lifecycle', () => { } }) - it('holds onConnected until the incremental source is ready after describe succeeds', async () => { - const api = new FakeApiClient() - api.holdGenerationReady = true + it('holds onConnected until the incremental source reports ready', async () => { + const source = new FakeGenerationSource() + source.holdReady = true let connected = 0 - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) await new Promise(resolve => setTimeout(resolve, 30)) - expect(connected).toBe(0) // describe alone must not announce - api.releaseGenerationReady() + expect(connected).toBe(0) + source.releaseReady() await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() } }) - it('rejects a generation whose source ends during readiness and retries', async () => { - const api = new FakeApiClient() - const firstDescribe = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls === 1 - ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) + it('accepts only the first readiness report from one generation', async () => { + const homes: string[] = [] + const source: ConnectionGenerationSource = (signal, ready) => { + ready({ home: '/first' }) + ready({ home: '/duplicate' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) } + const controller = new ConnectionController(source, { + onConnected: (host) => { homes.push(host.home) }, + }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(homes).toEqual(['/first']) }) + } finally { + controller.stop() + } + }) + + it('does not announce readiness after a stop queued from the ready callback', async () => { + const owner: { controller?: ConnectionController } = {} + let sourceCalls = 0 + const connected = vi.fn() + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + sourceCalls++ + ready({ home: '/h' }) + queueMicrotask(() => { owner.controller?.stop() }) + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + const controller = new ConnectionController(source, { onConnected: connected }, FAST) + owner.controller = controller + controller.start() + await vi.waitFor(() => { expect(sourceCalls).toBe(1) }) + expect(connected).not.toHaveBeenCalled() + }) + + it('rejects a generation whose source ends during readiness and retries', async () => { + const source = new FakeGenerationSource() + source.holdReady = true const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) }) - api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) + source.holdReady = false + source.end() await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) } finally { @@ -181,22 +144,22 @@ describe('connection lifecycle', () => { { label: 'ends normally', fail: () => Promise.resolve() }, { label: 'rejects with a non-Error reason', - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test fail: () => Promise.reject('fixture offline'), }, ])('retries when the generation source $label before reporting ready', async ({ fail }) => { - const api = new FakeApiClient() let sourceCalls = 0 let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, (signal, ready) => { + const source: ConnectionGenerationSource = (signal, ready) => { sourceCalls++ if (sourceCalls === 1) return fail() - ready() + ready({ home: '/h' }) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - }, { onConnected: () => { connected++ } }, FAST) + } + const controller = new ConnectionController(source, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(sourceCalls).toBe(2) }) @@ -208,19 +171,19 @@ describe('connection lifecycle', () => { }) it('rejects and retries a generation whose source never reports ready', async () => { - const api = new FakeApiClient() - api.suppressGenerationReady = true + const source = new FakeGenerationSource() + source.suppressReady = true let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const controller = new ConnectionController( - api, - api.generation, + source.source, { onConnected: () => { connected++ } }, { ...FAST, generationReadyTimeoutMs: 20 }, ) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) }) + await vi.waitFor(() => { expect(source.activeCount).toBeGreaterThan(0) }) + await new Promise(resolve => setTimeout(resolve, 45)) expect(connected).toBe(0) } finally { controller.stop() @@ -229,11 +192,11 @@ describe('connection lifecycle', () => { }) it('emits deduplicated connected/reconnecting state transitions', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -241,7 +204,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['connected']) - api.failStreams(new Error('torn')) + source.fail(new Error('torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) expect(states).toEqual(['connected', 'reconnecting', 'connected']) } finally { @@ -251,10 +214,10 @@ describe('connection lifecycle', () => { }) it('does not announce a generation stopped synchronously by its connected state sink', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: (state) => { states.push(state) @@ -264,59 +227,58 @@ describe('connection lifecycle', () => { controller.start() await vi.waitFor(() => { expect(states).toEqual(['connected']) }) - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) expect(connected).toBe(0) }) it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise - } + let sourceCalls = 0 const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const source: ConnectionGenerationSource = (signal, ready) => { + sourceCalls++ + if (sourceCalls <= 2) return Promise.reject(new Error('down')) + ready({ home: '/h' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) + await vi.waitFor(() => { expect(sourceCalls).toBe(3) }) await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission + expect(states).toEqual(['reconnecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() } }) - it('runs with no sinks at all (every callback slot optional)', async () => { - const api = new FakeApiClient() - const controller = new ConnectionController(api, api.generation, {}, FAST) + it('runs with no sinks at all', async () => { + const source = new FakeGenerationSource() + const controller = new ConnectionController(source.source, {}, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) - await new Promise(resolve => setTimeout(resolve, 20)) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) } finally { controller.stop() } }) - it('start() is idempotent (one loop, one stream set)', async () => { - const api = new FakeApiClient() + it('start() is idempotent', async () => { + const source = new FakeGenerationSource() let connected = 0 - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openGenerationCount).toBe(1) - expect(api.callsOf('host.describe')).toHaveLength(1) + expect(source.activeCount).toBe(1) } finally { controller.stop() } diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts deleted file mode 100644 index a2cb5f78b8..0000000000 --- a/packages/client/connection/tests/fake-api.client.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo -// data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). The generation source is a hand pump. -import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts' -import type { ConnectionGenerationSource } from '../src/client/connection.ts' -import { RpcId } from '../src/client/api.ts' - -export interface Deferred { - promise: Promise - resolve(value: T): void - reject(error: unknown): void -} - -/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ -export function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -let nextRpc = 0 - -export function ok(value: T): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } -} - - -type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } - -interface StreamConn { - feed(item: StreamItem): void -} - -export class FakeApiClient implements IApiClient { - /** Chronological call record: [method, payload]. */ - readonly calls: { method: string; payload: unknown }[] = [] - - // Programmable slots (defaults answer OK-empty); reassign per case. - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - onOpenPath: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ opened: true as const })) - - private readonly generationConns: StreamConn[] = [] - - readonly host: IApiClient['host'] = { - describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), - } - - // Payloads stay `unknown` (lint-lane note above); response rows are the real - // wire shapes so cases can program catalogs and skill lists without casts. - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) - - - readonly agentPresets: IApiClient['agentPresets'] = { - openDocument: (payload: { agentPreset: string }) => - this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly skills: IApiClient['skills'] = { - list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - } - - readonly settings: IApiClient['settings'] = { - openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly llm: IApiClient['llm'] = { - providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ - default: { provider: 'fixture', model: 'fixture' }, - routableProviders: [], - groups: [], - failures: [], - }))), - discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), - } - - /** When true, the source never reports ready. */ - suppressGenerationReady = false - - /** When true, ready callbacks remain parked until the test releases them. */ - holdGenerationReady = false - private heldOpens: (() => void)[] = [] - - releaseGenerationReady(): void { - const held = this.heldOpens - this.heldOpens = [] - for (const fire of held) fire() - } - - readonly generation: ConnectionGenerationSource = (signal, ready) => - this.openGeneration(signal, ready) - - /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ - endStreams(): void { - for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' }) - } - - failStreams(error: unknown): void { - for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error }) - } - - get openGenerationCount(): number { - return this.generationConns.length - } - - callsOf(method: string): unknown[] { - return this.calls.filter(c => c.method === method).map(c => c.payload) - } - - private record(method: string, payload: unknown, response: Promise): Promise { - this.calls.push({ method, payload }) - return response - } - - private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise { - const inbox: StreamItem[] = [] - let wake: (() => void) | null = null - const conn: StreamConn = { - feed: (item) => { - inbox.push(item) - wake?.() - }, - } - this.generationConns.push(conn) - if (this.holdGenerationReady) this.heldOpens.push(onOpen) - else if (!this.suppressGenerationReady) onOpen() - try { - while (!signal.aborted) { - while (inbox.length > 0) { - const item = inbox.shift() as StreamItem - if (item.kind === 'end') return - if (item.kind === 'fail') throw item.error - } - await new Promise((resolve) => { - wake = resolve - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - wake = null - } - } finally { - this.generationConns.splice(this.generationConns.indexOf(conn), 1) - } - } -} diff --git a/packages/client/connection/tests/fake-generation.client.ts b/packages/client/connection/tests/fake-generation.client.ts new file mode 100644 index 0000000000..83625bb55e --- /dev/null +++ b/packages/client/connection/tests/fake-generation.client.ts @@ -0,0 +1,80 @@ +/** Test-local programmable Connection generation source. */ +import type { ConnectionGenerationSource } from '../src/client/connection.ts' + +type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } + +interface StreamConnection { + feed(item: StreamItem): void +} + +/** Hand-pumped generation source for Connection lifecycle tests. */ +export class FakeGenerationSource { + private readonly connections: StreamConnection[] = [] + + /** When true, the source never reports ready. */ + suppressReady = false + + /** When true, ready callbacks remain parked until the test releases them. */ + holdReady = false + + private heldReady: Array<() => void> = [] + + /** Open one generation. */ + readonly source: ConnectionGenerationSource = (signal, ready) => this.open(signal, ready) + + /** Release every generation currently parked before readiness. */ + releaseReady(): void { + const held = this.heldReady + this.heldReady = [] + for (const fire of held) fire() + } + + /** End every active generation normally. */ + end(): void { + for (const connection of [...this.connections]) connection.feed({ kind: 'end' }) + } + + /** Fail every active generation. */ + fail(error: unknown): void { + for (const connection of [...this.connections]) connection.feed({ kind: 'fail', error }) + } + + /** Number of currently active generations. */ + get activeCount(): number { + return this.connections.length + } + + private async open( + signal: AbortSignal, + onReady: (host: { readonly home: string }) => void, + ): Promise { + const inbox: StreamItem[] = [] + let wake: (() => void) | null = null + const connection: StreamConnection = { + feed: (item) => { + inbox.push(item) + wake?.() + }, + } + this.connections.push(connection) + const ready = (): void => { onReady({ home: '/h' }) } + if (this.holdReady) this.heldReady.push(ready) + else if (!this.suppressReady) ready() + try { + while (!signal.aborted) { + while (inbox.length > 0) { + const item = inbox.shift() as StreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + wake = null + } + } finally { + this.connections.splice(this.connections.indexOf(connection), 1) + } + } +} diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts new file mode 100644 index 0000000000..d5dbee979e --- /dev/null +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -0,0 +1,71 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { BrowserAuth } from '../src/browser-auth.ts' +import { HostConnectionService } from '../src/rpc-host.ts' + +async function mounted(): Promise<{ + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + const fiber = ctx.plugin((pluginCtx) => { + new HostConnectionService(pluginCtx, [], {} as BrowserAuth) + }) + await fiber.await() + return { + connection: ctx.get('connection') as HostConnectionService, + dispose: () => fiber.dispose(), + } +} + +describe('Connection exact Fetch routes', () => { + it('dispatches owned methods and returns 404 for unclaimed requests', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async (request: Request) => + Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) + const dispose = connection.fetch.register({ + path: '/api/session.export', + methods: ['GET', 'HEAD'], + fetch: route, + }) + const shared = connection.createSharedFetchHandler('/api') + + const response = await shared.fetch(new Request( + 'http://host/api/session.export?sessionId=session-1', + )) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ query: 'session-1' }) + expect(route).toHaveBeenCalledOnce() + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + expect(post.status).toBe(404) + + await dispose() + const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + expect(withdrawn.status).toBe(404) + await disposeFiber() + }) + + it('rejects invalid and duplicate registrations', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const fetch = async (): Promise => new Response() + + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + .toThrow('invalid exact Fetch route') + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + .toThrow('declares no methods') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['GET', 'GET'], fetch, + })).toThrow('repeats a method') + const dispose = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], fetch, + }) + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).toThrow('already registered') + await dispose() + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).not.toThrow() + await disposeFiber() + }) +}) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 163fb414a6..ea5c3a764b 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -1,14 +1,10 @@ /** * Fixture commands/skills domains: session-addressed catalogs, execute - * parse/dispatch and its logged lifecycle pair, skill.list session resolution, - * and the FixtureApiClient dispatch rows. Commands answer on the Remote face - * and skills on the legacy API face, so both are driven here. + * parse/dispatch and its logged lifecycle pair, and skills/list Session resolution. */ 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, createFixtureFaces } from '../src/client/fixture.ts' +import { createFixtureFaces } from '../src/client/fixture.ts' /** Drive one commands Remote endpoint against the fixture state graph. */ async function callRemote( @@ -22,8 +18,6 @@ async function callRemote( } const sid = (id: string): SessionId => id as SessionId -let reqCount = 0 -const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${reqCount++}`), payload }) describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { @@ -162,26 +156,30 @@ describe('createFixtureApi commands/skills', () => { }) 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 { rpc } = createFixtureFaces() + const skills = await callRemote<{ skills: Array<{ name: string }> }>( + rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } }, + ) + expect(skills.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' } }) + const missingSession = await rpc.call('/api', 'skills/list', { + args: { request: { sessionId: sid('fx-nope') } }, + }) + expect(missingSession).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) }) -describe('FixtureApiClient command/skill dispatch', () => { - it('routes the Remote commands face and the legacy skill row through one state graph', async () => { - const client = new FixtureApiClient() - const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') }) +describe('fixture Connection command/skill dispatch', () => { + it('routes the Remote command and skill rows through one state graph', async () => { + const { rpc } = createFixtureFaces() + const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.length).toBeGreaterThan(0) const executed = await callRemote<{ commandId: string } | undefined>( - client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) expect(executed?.commandId).toBeTruthy() - 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) + const skills = await callRemote<{ skills: unknown[] }>( + rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } }, + ) + expect(skills.skills.length).toBeGreaterThan(0) }) }) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index de8cbf42d9..b7e76ec767 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { - ModelSelection, - RpcMessage, RpcRequest, RpcResponse, RpcResult, @@ -12,7 +10,7 @@ import { RpcId } from '../src/client/api.ts' import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import { - FixtureApiClient, + createFixtureConnectionRpc, createFixtureFaces, type FixtureOptions, } from '../src/client/fixture.ts' @@ -20,6 +18,8 @@ import type { ClientConnectionRpc, ConnectionRpcResult, } from '../src/rpc.ts' import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' +import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -175,6 +175,7 @@ type FixtureSessionClient = { } interface FixtureSessionRemote { + modelCatalog(): Promise> follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable control(signal: AbortSignal): AsyncIterable } @@ -286,7 +287,7 @@ interface FixtureRemoteEventStream extends AsyncIterable } -type FixtureTestApi = ReturnType['api'] & { +type FixtureTestApi = { /** The directory-picking Remote namespace as the fixture serves it. */ readonly directoryPickerRemote: { pick: () => Promise> @@ -305,8 +306,8 @@ type FixtureTestApi = ReturnType['api'] & { /** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { - const { api, rpc } = createFixtureFaces(options) - return Object.assign(api, { + const { rpc } = createFixtureFaces(options) + return { directoryPickerRemote: { pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as Promise>, @@ -325,7 +326,7 @@ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), answerRemoteEvent: (result: FixtureRemoteEventResult) => rpc.call('/api', '$events/result', { args: result }), - }) + } } /** The fixture's Credentials Remote endpoints over the shared RPC carrier. */ @@ -446,6 +447,8 @@ function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { return stream as AsyncIterable } return { + modelCatalog: () => rpc.call('/api', 'session/modelCatalog', { args: {} }) as + Promise>, follow: (sessionId, signal) => open('session/follow', { request: { address: { kind: 'session', sessionId } }, }, signal), @@ -732,10 +735,10 @@ describe('createFixtureApi', () => { it('serves grouped models and keeps a selection for later history and fixture requests', async () => { const api = createFixtureApi() const sessionId = sid('fx-alpha') - const catalog = await api.llm.models(req({})) - if (!catalog.result.ok) throw new Error('models failed') - expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) - expect(catalog.result.value.groups[0]?.models.map(model => model.id)) + const catalog = await api.sessionRemote.modelCatalog() + if (!catalog.ok) throw new Error('models failed') + expect(catalog.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) + expect(catalog.value.groups[0]?.models.map(model => model.id)) .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) const selected = await api.sessions.selectModel(req({ @@ -1099,16 +1102,6 @@ describe('createFixtureApi', () => { expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) - it('describe answers the fixture identity', async () => { - const api = createFixtureApi() - const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ - ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, - }) - const empty = await createFixtureApi({ empty: true }).host.describe(req({})) - expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) - }) - it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() const created = await api.directoryPickerRemote.createDirectory('/', 'srv') @@ -1609,38 +1602,16 @@ describe('createFixtureApi', () => { }) }) -describe('FixtureApiClient (protocol-level fake carrier)', () => { +describe('fixture Connection RPC', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) - it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => { - const client = new FixtureApiClient() - // Protected at compile time only; reach it directly to pin the tripwire message. - expect(() => (client as unknown as { doFetch(): Promise }).doFetch()).toThrow(/doFetch must be unreachable/) - }) - - it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => { - const client = new FixtureApiClient() - const tapped: RpcMessage[] = [] - client.subscribeEnvelopes(batch => tapped.push(...batch)) - const response = await client.host.describe({}) - expect(response.result.ok).toBe(true) - await vi.waitFor(() => { - const kinds = tapped.map(m => m.type) - expect(kinds).toContain('client-request') - expect(kinds).toContain('server-response') - }) - const request = tapped.find(m => m.type === 'client-request') - const reply = tapped.find(m => m.type === 'server-response') - expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier - }) - - it('covers the whole unary dispatch table', async () => { - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) - const workspaces = createWorkspaceClient(client.rpc) + it('covers the migrated Remote dispatch table', async () => { + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) + const workspaces = createWorkspaceClient(rpc) expect((await sessions.search( { query: 'fixture' }, new AbortController().signal, @@ -1651,8 +1622,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true) - expect((await client.host.describe({})).result.ok).toBe(true) - expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0) + expect((await readWorkspaceBaseline(createWorkspaceRemote(rpc))).items).not.toHaveLength(0) const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') @@ -1668,13 +1638,13 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) it('folds the goal lifecycle over the Goal Remotes', async () => { - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) const created = await sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId const goal = (endpoint: string, args: Record) => - client.rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) + rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) // create → edit → pause → resume → complete → clear; each mutation advances the CAS // revision by one (state rides the projection frames). @@ -1713,17 +1683,17 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { vi.stubGlobal('location', { search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', }) - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) - const workspaces = createWorkspaceClient(client.rpc) - const workspaceRemote = createWorkspaceRemote(client.rpc) + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) + const workspaces = createWorkspaceClient(rpc) + const workspaceRemote = createWorkspaceRemote(rpc) await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const hostAbort = new AbortController() const workspaceAbort = new AbortController() const hostFrames = collectValues( - openFixtureRemoteEvents(client.rpc, hostAbort.signal), + openFixtureRemoteEvents(rpc, hostAbort.signal), hostAbort, frames => frames.length === 1, ) @@ -1757,8 +1727,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('maps attach-failure and dropped-response query scenarios', async () => { vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) - const partial = new FixtureApiClient() - const partialResult = await createSessionClient(partial.rpc).create({ + const partial = createFixtureConnectionRpc() + const partialResult = await createSessionClient(partial).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), }) @@ -1768,8 +1738,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) - const dropped = new FixtureApiClient() - await expect(createSessionClient(dropped.rpc).create({ + const dropped = createFixtureConnectionRpc() + await expect(createSessionClient(dropped).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), })).rejects.toThrow(/dropped session\.create response/) diff --git a/packages/client/connection/tests/generation.client.spec.ts b/packages/client/connection/tests/generation.client.spec.ts new file mode 100644 index 0000000000..0deace5dba --- /dev/null +++ b/packages/client/connection/tests/generation.client.spec.ts @@ -0,0 +1,65 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apply, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '../src/client/index.ts' + +type BrowserGlobal = { + location?: { hostname: string; search: string } +} + +const contexts = new Set() + +afterEach(async () => { + vi.restoreAllMocks() + delete (globalThis as BrowserGlobal).location + await Promise.all([...contexts].map(async ctx => ctx.fiber.dispose())) + contexts.clear() +}) + +async function mount(): Promise { + ;(globalThis as BrowserGlobal).location = { hostname: 'localhost', search: '?fixture' } + const ctx = new Context() + contexts.add(ctx) + await ctx.plugin({ apply, inject: [] }) + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('fixture did not provide Connection') + return connection +} + +describe('Connection generation facts', () => { + it('publishes ready-frame Host facts and retracts them when the loop stops', async () => { + const connection = await mount() + const source: ConnectionGenerationSource = (signal, ready) => { + ready({ home: '/home/from-ready' }) + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + connection.registerGenerationSource(source) + const seen: Array = [] + const stopListening = connection.generation.subscribe(() => { + seen.push(connection.generation.getSnapshot()?.host.home) + }) + const loop = connection.start({}, { + backoffBaseMs: 1, + backoffFactor: 1, + backoffMaxMs: 1, + generationReadyTimeoutMs: 100, + }) + + await vi.waitFor(() => { + expect(connection.generation.getSnapshot()).toEqual({ + id: 1, + host: { home: '/home/from-ready' }, + }) + }) + loop.stop() + expect(connection.generation.getSnapshot()).toBeUndefined() + expect(seen).toEqual(['/home/from-ready', undefined]) + stopListening() + }) +}) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 4a5121b97b..268b37e75c 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -6,11 +6,9 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' -import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' +import { API_PATH, RpcId, apply, inject, type ClientRequest, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' @@ -94,7 +92,6 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ const upgrades: WebUpgradeRoute[] = [] provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() return { @@ -131,7 +128,6 @@ describe('connection node half', () => { ctx.provide('attachments', { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 }, } as AttachmentStore) - ctx.provide('apiProxy', {} as ApiProxy) await expect(apply(ctx, { maxRequestBodyBytes: 1024 })) .rejects.toThrow(/must be at least .* aggregate image limit/) expect(routes).toHaveLength(0) @@ -143,7 +139,6 @@ describe('connection node half', () => { const ctx = new Context() provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) @@ -174,8 +169,8 @@ describe('connection node half', () => { it('requires the same browser session for every method on every trusted authority', async () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ - 'host.openPath', - 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', + 'session/openWorkspacePath', + 'llm/discoverModels', 'skills/list', 'settings/openAgentPresetDirectory', ] for (const method of methods) { const denied = fakeResponse() @@ -243,7 +238,7 @@ describe('connection node half', () => { await dispose() }) - it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { + it('provides a disposable dedicated RPC channel', async () => { const ctx = new Context() const routes: WebRoute[] = [] provideBrowserCredentials(ctx) @@ -292,12 +287,11 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) - it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { + it('dispatches claimed /api endpoints and withdraws the claim', async () => { const ctx = new Context() const routes: WebRoute[] = [] provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle @@ -500,17 +494,17 @@ describe('connection node half over a real HTTP server', () => { const { port, close } = await serve(routes) try { const methods = [ - 'settings.openDocument', - 'host.openPath', - 'llm.discoverModels', - 'agentPreset.openDocument', - 'llm.providers', 'llm.models', + 'settings/openSettingsDocument', + 'session/openWorkspacePath', + 'llm/discoverModels', 'skills/list', + 'settings/openAgentPresetDirectory', + 'llm/listProviders', 'session/modelCatalog', ] for (const method of methods) { expect([method, await call(port, method, 'localhost')]).toEqual([method, 401]) expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401]) } - expect(await call(port, 'settings.openDocument', 'other.example')).toBe(403) + expect(await call(port, 'settings/openSettingsDocument', 'other.example')).toBe(403) const declaredCookie = browserCookie(connection, 'harness.example') for (const method of methods) { @@ -519,7 +513,7 @@ describe('connection node half over a real HTTP server', () => { const loopbackAuthority = `127.0.0.1:${String(port)}` expect(await call( port, - 'settings.openDocument', + 'settings/openSettingsDocument', loopbackAuthority, browserCookie(connection, loopbackAuthority), )).toBe(404) diff --git a/packages/client/connection/tests/rpc-schema.host.spec.ts b/packages/client/connection/tests/rpc-schema.host.spec.ts new file mode 100644 index 0000000000..1d34e58ac3 --- /dev/null +++ b/packages/client/connection/tests/rpc-schema.host.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { RpcId, transportError } from '../src/rpc.ts' +import { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from '../src/rpc-schema.ts' +import { z } from 'zod' + +describe('Connection RPC schema', () => { + it('brands any validated string correlation id', () => { + expect(RpcId('abc')).toBe('abc') + expect(rpcIdSchema.parse('')).toBe('') + expect(() => rpcIdSchema.parse(42)).toThrow() + }) + + it('folds transport exceptions into an internal failure', () => { + expect(transportError(new Error('wire down'))).toEqual({ + ok: false, + error: { code: 'internal', message: 'wire down', details: {} }, + }) + expect(transportError('raw')).toMatchObject({ + ok: false, + error: { code: 'internal', message: 'raw' }, + }) + }) + + it('validates generic failures and both result branches', () => { + expect(rpcErrorSchema.parse({ code: 'domain-failure', message: 'failed', details: { id: 'x' } })) + .toEqual({ code: 'domain-failure', message: 'failed', details: { id: 'x' } }) + expect(() => rpcErrorSchema.parse({ code: 1, message: 'failed', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'failed', message: 'failed', details: [] })).toThrow() + + const schema = rpcResultSchema(z.object({ n: z.number() })) + expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } }) + expect(schema.parse({ ok: false, error: { code: 'failed', message: 'x', details: {} } })) + .toMatchObject({ ok: false }) + expect(() => schema.parse({ ok: true, error: {} })).toThrow() + }) + + it('validates both envelope directions and valueless success', () => { + const request = { type: 'client-request', rpcId: 'r1', method: 'settings/describe', payload: { args: {} } } + const response = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } } + expect(clientRequestSchema.parse(request).method).toBe('settings/describe') + expect(serverResponseSchema.parse(response).rpcId).toBe('r1') + for (const message of [request, response]) expect(rpcMessageSchema.parse(message)).toBeTruthy() + expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() + expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + expect(serverResponseSchema.parse({ + type: 'server-response', rpcId: 'r1', result: { ok: true }, + }).rpcId).toBe('r1') + }) +}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 6d5533c9b7..5732fd31f1 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -13,7 +13,6 @@ "src/client/index.ts", "src/client/random-uuid.ts", "src/client/rpc.ts", - "src/client/web-api-client.ts", "src/loopback-hostname.ts", "src/rpc.ts" ], @@ -39,9 +38,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../host/apiproxy" - }, { "path": "../../host/directory-picker" }, diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index ad9bacc704..baeeccf4a4 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -14,6 +14,7 @@ "src/invariant.ts", "src/loopback-hostname.ts", "src/rpc-host.ts", + "src/rpc-schema.ts", "src/rpc.ts" ], "references": [ @@ -24,13 +25,16 @@ "path": "../../credentials/credentials" }, { - "path": "../../host/apiproxy" + "path": "../../core/session" }, { "path": "../../host/webserver" }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 3ad65222f5..35f1beed3c 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 355ba7000e..466734cc9b 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 6aa1a35009..5b7a0a5b40 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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 packages/client/modules/README.md -README.md: 4c81da74e5efbbbcdb35147431fc03252d51c94a -README.zh.md: 3e476c57606e64a4837ca97f4298faf124849e4c +README.md: 414d34af202ee4ab24e1b7570697ad63bd8cdbc8 +README.zh.md: e6adc7b7d0265e52d5108e241a8f8a551976536d diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 4c81da74e5..414d34af20 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -63,7 +63,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff ### Incremental composition -The node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per name and never expires; bundle content changes reach the graph only through `rebuilt()` (the HMR hook). +The node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook). The node half snapshots each client bundle and available source map before publication. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. Every combo map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces, so startup does not hash every plugin; HMR hashes only an artifact reported as changed. Advertised responses are immutable, and an unknown combination or revision returns 404. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 3e476c5760..e6adc7b7d0 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -63,7 +63,7 @@ application combo 脚本在启动时注册插件 factory;模块主体仍保持 ### 增量组合 -node 半侧逐包增量扫描——没有全量重扫路径。每次 `internal/plugin` 发出都会把该 fiber 的 entry 名标脏;一个微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 播种同一脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按名缓存且永不过期;bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。 +node 半侧逐包增量扫描——没有全量重扫路径。每次 `internal/plugin` 发出都会把该 fiber 的 entry 名标脏;一个微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 播种同一脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。 node 半侧会在发布前快照每个客户端 bundle 及其现有 source map。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,所以启动时不哈希每个插件;HMR 只哈希被报告为已变化的产物。已公告响应不可变;未知组合或 revision 返回 404。 diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 62926fa35b..1e608f4c58 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 44bfa54f61..98b6fa5019 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -15,21 +15,23 @@ * against the live loader entries. The activation pass seeds the same dirty * set with all current entries and flushes synchronously, so first scan and * steady state share one implementation. Package metadata (including the - * negative "not a client package" verdict) is cached per name and never - * expires — plugin-set changes take effect on restart; bundle content - * changes reach the graph only through + * negative "not a client package" verdict) is cached per Loader specifier and + * owning-tree base URL until restart. The manifest package name identifies + * the browser module; distinct active Loader sources for that package are a + * composition error. Bundle content changes reach the graph only through * {@link ClientModuleRegistry.rebuilt}. * @module @deepseek-ai/dsh-client-modules */ import { createHash, randomBytes } from 'node:crypto' -import { readFileSync, statSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/cordis-plugin-loader' +import type { Entry } from '@deepseek-ai/cordis-plugin-loader' import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' import { optionalStringArray, stripClientSuffix } from './client/manifest.ts' import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts' @@ -80,11 +82,26 @@ export interface ClientArtifactBaseline { readonly size: number } -/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */ +/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */ interface PkgMeta extends WebBootRowFields { clientPath: string } +interface ResolvedPkgMeta { + packageName: string + meta: PkgMeta +} + +/** One active Loader source and the browser package manifest it resolves to. */ +interface ClientPackageSource extends ResolvedPkgMeta { + /** Loader specifier from the active row. */ + loaderName: string + /** Resolution base of the config tree that owns the row. */ + baseUrl: string + /** Stable cache and contribution key for this source. */ + sourceKey: string +} + /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */ const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch' @@ -129,6 +146,10 @@ class ClientPackageCompositionError extends AggregateError { /** One composed table row: the wire entry plus the resolved package metadata behind it. */ interface WebPluginRecord { entry: WebBootEntry + /** Loader specifier whose active row contributes this browser module. */ + loaderName: string + /** Loader resolution input that selected this package instance. */ + sourceKey: string meta: PkgMeta /** Exact build artifact included in the startup batches. */ bundle: Buffer @@ -167,6 +188,15 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$ /** Debugger source name appended to page bundles in the WebWorker image. */ const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/ +/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */ +function exactPackageSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('@')) { + const parts = specifier.split('/') + return parts.length === 2 && parts.every(Boolean) ? specifier : undefined + } + return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined +} + /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */ function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined { if (value === undefined) return undefined @@ -504,14 +534,13 @@ export class ClientModuleRegistry extends Service { static inject = ['webServer', 'loader'] private readonly table = new Map() - // Negative verdicts (unresolvable specifier — builtins like cordis:include, - // subpath rows — or a package without a web `dsh.client` declaration) are - // cached as null and never expire: plugin-set changes take effect on restart. - private readonly pkgMeta = new Map() + private readonly sources = new Map() + // Resolution is entry-local: the same specifier can resolve differently in + // separate config trees. Negative verdicts remain stable until restart. + private readonly pkgMeta = new Map() private readonly rebuildListeners = new Set<(id: string, rev: string) => void>() private readonly graphListeners = new Set<() => void>() private readonly dirty = new Set() - private readonly resolvePkgJson: (spec: string) => string private readonly initialRevisionNonce = randomBytes(8).toString('hex') private nextInitialRevision = 0 private responses = new Map() @@ -527,16 +556,6 @@ export class ClientModuleRegistry extends Service { */ constructor(ctx: Context) { super(ctx, 'clientModules') - // Resolution anchor: the config tree's baseUrl (the cordis.yml directory, - // whose package declares every composed plugin as a dependency). The - // modules package's own URL would miss sibling packages under pnpm's - // isolated node_modules. - if (ctx.baseUrl === undefined) { - throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages') - } - const require = createRequire(ctx.baseUrl) - this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`) - // Subscribe before seeding so a fiber arriving mid-activation lands in the // same dirty set (Set idempotence makes the overlap harmless). An entry-less // fiber is a child plugin or a manual mount — never a loader row; O(1) drop. @@ -716,31 +735,31 @@ export class ClientModuleRegistry extends Service { } } - private resolveMeta(pkgName: string): PkgMeta | null { - const cached = this.pkgMeta.get(pkgName) + private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null { + const sourceKey = this.sourceKey(loaderName, baseUrl) + const cached = this.pkgMeta.get(sourceKey) if (cached !== undefined) return cached - let pkgPath: string - try { - pkgPath = this.resolvePkgJson(pkgName) - } catch { + const located = this.locatePkgJson(loaderName, baseUrl) + if (located === undefined) { // Not a resolvable package root: loader builtins (cordis:include) and // subpath entries (…/gateway) land here — permanently not a client row. - this.pkgMeta.set(pkgName, null) + this.pkgMeta.set(sourceKey, null) return null } + const { packageName, path: pkgPath } = located const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record const dsh = pkg.dsh const decl = parseDshClient( - pkgName, + packageName, dsh !== null && typeof dsh === 'object' ? (dsh as Record).client : undefined, ) if (decl === undefined || decl.platform !== 'web') { - this.pkgMeta.set(pkgName, null) + this.pkgMeta.set(sourceKey, null) return null } - const clientRel = clientExportOf(pkgName, pkg.exports) + const clientRel = clientExportOf(packageName, pkg.exports) if (clientRel === undefined) { - throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`) + throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`) } const meta: PkgMeta = { clientPath: join(dirname(pkgPath), clientRel), @@ -748,8 +767,87 @@ export class ClientModuleRegistry extends Service { external: decl.external ?? [], immediately: decl.immediately === true, } - this.pkgMeta.set(pkgName, meta) - return meta + const resolved = { packageName, meta } + this.pkgMeta.set(sourceKey, resolved) + return resolved + } + + /** + * Locate the manifest of the package the Loader mounts for a row. The row's + * module location is authoritative: the specifier resolves through the same + * Loader resolution that imported the row's host half — including any + * active ESM hooks — and the nearest ancestor manifest declaring the name + * owns the module. Tree-anchored `require` resolution remains only for + * runtimes without Node internals. + * @param loaderName - module specifier of the loader row. + * @param baseUrl - resolution base of the tree that owns the row. + * @returns the manifest path, or `undefined` when the name resolves to no package root. + */ + private locatePkgJson(loaderName: string, baseUrl: string): { path: string; packageName: string } | undefined { + if (loaderName.startsWith('cordis:')) return undefined + const pathLike = loaderName.startsWith('.') || loaderName.startsWith('file:') || isAbsolute(loaderName) + const expectedPackageName = pathLike ? undefined : exactPackageSpecifier(loaderName) + if (!pathLike && expectedPackageName === undefined) return undefined + const internal = this.ctx.loader.internal + if (internal === undefined || typeof Reflect.get(internal, 'resolveSync') !== 'function') { + if (expectedPackageName === undefined) { + const moduleUrl = loaderName.startsWith('file:') + ? loaderName + : isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href + return this.nearestPackage(moduleUrl) + } + try { + return { + path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`), + packageName: expectedPackageName, + } + } catch { + // Without Node internals the owning tree is the only resolver; an + // unresolvable name is classified exactly as below. + return undefined + } + } + let moduleUrl: string + try { + moduleUrl = internal.version === 'v2' + ? internal.resolveSync(baseUrl, { specifier: loaderName, attributes: {} }).url + : internal.resolveSync(loaderName, baseUrl, {}).url + } catch { + // The Loader cannot resolve the name: its row cannot have imported, so + // the name is permanently not a client row. + return undefined + } + return this.nearestPackage(moduleUrl, expectedPackageName) + } + + private nearestPackage( + moduleUrl: string, + expectedPackageName?: string, + ): { path: string; packageName: string } | undefined { + if (!moduleUrl.startsWith('file:')) return undefined + let dir = dirname(fileURLToPath(moduleUrl)) + while (true) { + const candidate = join(dir, 'package.json') + if (existsSync(candidate)) { + try { + const name = (JSON.parse(readFileSync(candidate, 'utf8')) as { name?: unknown }).name + if (typeof name === 'string' && (expectedPackageName === undefined || name === expectedPackageName)) { + return { path: candidate, packageName: name } + } + } catch { + // An unreadable or malformed intermediate manifest cannot own the + // module; keep walking toward the declaring package root. + } + } + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return undefined + } + + private sourceKey(loaderName: string, baseUrl: string): string { + return `${baseUrl}\0${loaderName}` } /** Capture the bundle stats before reading its bytes. */ @@ -800,26 +898,72 @@ export class ClientModuleRegistry extends Service { } } - /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ - private processOne(entryName: string): boolean { - let qualifies = false + /** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */ + private processOne(entryName: string, onError: (err: Error) => void): boolean { + const nextSources = new Map() for (const entry of this.ctx.loader.entries()) { - if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) { - qualifies = true - break + if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue + const source = this.resolveSource(entry) + if (source !== undefined) nextSources.set(source.sourceKey, source) + } + + const affectedPackages = new Set() + for (const [sourceKey, source] of this.sources) { + if (source.loaderName !== entryName) continue + affectedPackages.add(source.packageName) + if (!nextSources.has(sourceKey)) this.sources.delete(sourceKey) + } + for (const [sourceKey, source] of nextSources) { + affectedPackages.add(source.packageName) + this.sources.set(sourceKey, source) + } + let changed = false + for (const packageName of affectedPackages) { + try { + if (this.reconcilePackage(packageName)) changed = true + } catch (error) { + onError(error instanceof Error ? error : new Error(String(error))) } } - if (!qualifies) return this.table.delete(entryName) - if (this.table.has(entryName)) return false - const meta = this.resolveMeta(entryName) - if (meta === null) return false + return changed + } + + private resolveSource(entry: Entry): ClientPackageSource | undefined { + const loaderName = entry.options.name + const baseUrl = entry.parent.tree.ctx.baseUrl + if (baseUrl === undefined) { + throw new Error(`client-modules: loader entry ${loaderName} has no resolution base URL`) + } + const resolved = this.resolveMeta(loaderName, baseUrl) + if (resolved === null) return undefined + return { ...resolved, loaderName, baseUrl, sourceKey: this.sourceKey(loaderName, baseUrl) } + } + + private reconcilePackage(packageName: string): boolean { + const sources: ClientPackageSource[] = [] + for (const source of this.sources.values()) { + if (source.packageName === packageName) sources.push(source) + } + if (sources.length > 1) { + const locations = sources + .map(source => `${JSON.stringify(source.loaderName)} from ${source.baseUrl}`) + .join(', ') + throw new Error( + `client-modules: package ${packageName} resolves from multiple active Loader sources: ${locations}; remove one entry`, + ) + } + const source = sources[0] + if (source === undefined) return this.table.delete(packageName) + if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false // The opaque initial rev rides the row until HMR observes a file change; - // a fiber restart reuses the existing row without inspecting bytes. - const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath) + // a fiber restart from the same source reuses the existing row. + const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath) const rev = this.allocateInitialRevision() - this.table.set(entryName, { - entry: graphRow(entryName, rev, meta), - meta, + this.table.set(packageName, { + entry: graphRow(packageName, rev, source.meta), + loaderName: source.loaderName, + sourceKey: source.sourceKey, + meta: source.meta, bundle: snapshot.bundle, baseline: snapshot.baseline, ...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }), @@ -832,7 +976,7 @@ export class ClientModuleRegistry extends Service { for (const entryName of [...this.dirty]) { this.dirty.delete(entryName) try { - if (this.processOne(entryName)) changed = true + if (this.processOne(entryName, onError)) changed = true } catch (error) { // Steady state: one broken package must not poison the others; the // activation pass aggregates these into a loud throw instead. diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index f55da4e017..5553c94c8e 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -7,8 +7,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' -import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts' @@ -58,13 +58,26 @@ function writeBuiltPackage(packageName: string, client: Record) } /** Construct the node-half service and capture its plugin-bundle route. */ -function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } { +function constructWithRoute( + packageNames: string[], + options: { + contextBaseUrl?: string + entryBaseUrl?: string + internal?: NonNullable + } = {}, +): { context: Context; service: ClientModuleRegistry; route: WebRoute } { const ctx = new Context() - ctx.baseUrl = pathToFileURL(root!).href + '/' + ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/' ctx.provide('loader', { + internal: options.internal, *entries() { for (const packageName of packageNames) { - yield { options: { name: packageName }, fiber: {}, disabled: false } + yield { + options: { name: packageName }, + fiber: {}, + disabled: false, + parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } }, + } } }, }) @@ -80,7 +93,7 @@ function constructWithRoute(packageNames: string[]): { service: ClientModuleRegi ctx.provide('webServer', webServer as WebServer) const service = new ClientModuleRegistry(ctx) if (route === undefined) throw new Error('client bundle route was not registered') - return { service, route } + return { context: ctx, service, route } } /** Construct the node-half service over the enabled fixture entries. */ @@ -227,6 +240,171 @@ describe('HTML bootstrap facade', () => { }) describe('client bundle activation', () => { + it.each(['v1', 'v2'] as const)( + 'resolves %s package metadata from the owning entry tree', + (version) => { + const packageName = `@fixture/entry-base-${version}` + const clientPath = writePackage(packageName) + const hostPath = join(dirname(clientPath), 'index.js') + mkdirSync(dirname(hostPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/' + const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/' + const calls: unknown[][] = [] + const resolveSync = (...args: unknown[]) => { + calls.push(args) + return { format: 'module' as const, url: pathToFileURL(hostPath).href } + } + const internal = { version, resolveSync } + + const { service } = constructWithRoute([packageName], { + contextBaseUrl, + entryBaseUrl, + internal: internal as NonNullable, + }) + + expect(calls).toEqual(version === 'v2' + ? [[entryBaseUrl, { specifier: packageName, attributes: {} }]] + : [[packageName, entryBaseUrl, {}]]) + expect(service.clientPath(packageName)).toBe(clientPath) + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }, + ) + + it('derives the browser module id from a file entry owning manifest', () => { + const packageName = '@fixture/file-entry' + const clientPath = writePackage(packageName) + const hostPath = join(dirname(clientPath), 'index.js') + mkdirSync(dirname(hostPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + + const service = construct([pathToFileURL(hostPath).href]) + + expect(service.clientPath(packageName)).toBe(clientPath) + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }) + + it.each(['relative', 'absolute'] as const)( + 'finds the owning manifest through the %s-path fallback without Node loader internals', + (kind) => { + const packageName = `@fixture/${kind}-fallback-entry` + const clientPath = writePackage(packageName) + const packageRoot = dirname(dirname(clientPath)) + const hostPath = join(packageRoot, 'index.js') + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const loaderName = kind === 'relative' ? './index.js' : hostPath + + const { service } = constructWithRoute([loaderName], { + entryBaseUrl: pathToFileURL(packageRoot).href + '/', + }) + + expect(service.clientPath(packageName)).toBe(clientPath) + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }, + ) + + it.each(['v1', 'v2', 'worker'] as const)( + 'derives a file entry package id through the %s Loader resolver', + (version) => { + const packageName = `@fixture/file-entry-${version}` + const clientPath = writePackage(packageName) + const hostPath = join(dirname(clientPath), 'index.js') + mkdirSync(dirname(hostPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const loaderName = pathToFileURL(hostPath).href + const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/' + const calls: unknown[][] = [] + const resolveSync = (...args: unknown[]) => { + calls.push(args) + return { format: 'module' as const, url: loaderName } + } + const internal = { version, resolveSync } + + const { service } = constructWithRoute([loaderName], { + entryBaseUrl, + internal: internal as NonNullable, + }) + + expect(calls).toEqual(version === 'v2' + ? [[entryBaseUrl, { specifier: loaderName, attributes: {} }]] + : [[loaderName, entryBaseUrl, {}]]) + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }, + ) + + it('rejects distinct active Loader sources for one browser package', () => { + const packageName = '@fixture/duplicate-source' + const clientPath = writePackage(packageName) + const hostPath = join(dirname(clientPath), 'index.js') + mkdirSync(dirname(hostPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const alias = './duplicate-source.js' + const internal = { + version: 'v2' as const, + resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }), + } + + expect(() => constructWithRoute([packageName, alias], { + internal: internal as unknown as NonNullable, + })).toThrow( + `client-modules: package ${packageName} resolves from multiple active Loader sources:`, + ) + }) + + it('promotes the remaining Loader source after the selected alias unloads', async () => { + const packageName = '@fixture/duplicate-source-recovery' + const clientPath = writePackage(packageName) + const hostPath = join(dirname(clientPath), 'index.js') + mkdirSync(dirname(hostPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const alias = './duplicate-source-recovery.js' + const entries = [packageName] + const internal = { + version: 'v2' as const, + resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }), + } + const { context, service } = constructWithRoute(entries, { + internal: internal as unknown as NonNullable, + }) + const firstRevision = service.graph().entries[0]!.rev + const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined) + + entries.push(alias) + emitLoaderEntryChange(context, alias) + await Promise.resolve() + expect(warning).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining(`package ${packageName} resolves from multiple active Loader sources`) as string, + })) + expect(service.graph().entries[0]!.rev).toBe(firstRevision) + + entries.splice(entries.indexOf(packageName), 1) + emitLoaderEntryChange(context, packageName) + await Promise.resolve() + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + expect(service.graph().entries[0]!.rev).not.toBe(firstRevision) + expect(service.clientPath(packageName)).toBe(clientPath) + }) + + it('uses owning-tree package resolution for an import-only Worker module loader', () => { + const packageName = '@fixture/worker-loader' + writeBuiltPackage(packageName, {}) + const internal = { + version: 'worker', + import: async () => ({}), + } as unknown as NonNullable + + const { service } = constructWithRoute([packageName], { internal }) + + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }) + it('allows sibling dsh roles', () => { const currentName = '@fixture/current-client-field' const clientPath = writePackage(currentName, { @@ -564,6 +742,12 @@ describe('client bundle activation', () => { }) }) +function emitLoaderEntryChange(context: Context, name: string): void { + context.emit('internal/plugin', { + entry: { options: { name } }, + } as unknown as Fiber) +} + describe('shared module declarations', () => { it('accepts external requests and carries them onto the graph row', () => { const packageName = '@fixture/shared-declared' diff --git a/packages/client/store/package.json b/packages/client/store/package.json index f2d04490ba..e6b8eb47e6 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index dfebafe874..984a6a6828 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 3ff87b2d8f..042caf6f1b 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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 packages/client/ui-agent-preset/README.md -README.md: 1f07b034839dafcb0794d1a2e41b5015f5a55df0 -README.zh.md: 2aa5420f2092d974e293ddce97d34517c63e37b8 +README.md: 60a7f0ec9356c6107a32bb7c828746d54511d0f6 +README.zh.md: 64663fd76e0a8e3fd4cb799b05e81db7b62c0bde diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 1f07b03483..60a7f0ec93 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card

Implementation internals — click to expand -Options and the current default both come from one `agentPreset.list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPreset.read`, `copy`, `openDocument`, `remove`, `list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, remove, and openDocument manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/changed`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 2aa5420f20..64663fd76e 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -选项与当前默认值都来自同一次 `agentPreset.list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPreset.read`、`copy`、`openDocument`、`remove`、`list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、remove 与 openDocument 管理名单并驱动宿主桌面。分区在自身操作、`settings/changed` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。 +选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 60a91df206..7ec49fd96f 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index dda3c9d26e..4926d7c001 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -11,7 +11,6 @@ * before-the-fact, while the header only reports what a session already runs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the Session Controller service merge (ctx.sessions). import type {} from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -51,7 +50,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ] /** @@ -59,13 +58,12 @@ export const inject = [ * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const { api } = ctx.get('connection') as ConnectionHandle const settingsWire = { settings: ctx.remote.settings } const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController({ ...api, ...settingsWire }, ctx.remote, () => { + const section = new AgentPresetSectionController(ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 50d7650703..b8e9f8b6e2 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -5,7 +5,7 @@ export type AgentPresetSettingsKey = | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' | 'presetStandardName' | 'presetStandardDescription' - | 'presetCodeName' | 'presetCodeDescription' + | 'presetPtcName' | 'presetPtcDescription' | 'presetMinimalName' | 'presetMinimalDescription' | 'presetCordisName' | 'presetCordisDescription' | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' @@ -37,9 +37,9 @@ export const en: Record = { presetStandardName: 'Standard mode', presetStandardDescription: 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', - presetCodeName: 'PTC mode', - presetCodeDescription: - 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', + presetPtcName: 'PTC mode', + presetPtcDescription: + 'All Standard mode capabilities, with tools exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: 'Two-tool coding agent with persistent bash and str_replace_editor.', @@ -101,8 +101,8 @@ export const zh: Record = { view: '查看', presetStandardName: '标准模式', presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。', - presetCodeName: 'PTC 模式', - presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', + presetPtcName: 'PTC 模式', + presetPtcDescription: '具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', presetCordisName: '创造模式', @@ -170,7 +170,7 @@ interface PresetLocaleKeys { const BUILT_IN_PRESET_KEYS: Readonly>> = { standard: { name: 'presetStandardName', description: 'presetStandardDescription' }, - code: { name: 'presetCodeName', description: 'presetCodeDescription' }, + ptc: { name: 'presetPtcName', description: 'presetPtcDescription' }, minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' }, cordis: { name: 'presetCordisName', description: 'presetCordisDescription' }, } diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 099d92b186..43db65686d 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,8 +14,7 @@ * more than the row it targeted. */ -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -134,8 +133,7 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: SettingsWireFace & Pick, - private readonly remote: Pick, + private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is @@ -169,13 +167,13 @@ export class AgentPresetSectionController { // Issued together: one round trip decides the page, and a load that waited // for them in turn would hold the section in `loading` twice as long, // where a concurrent reload silently returns instead of refreshing. - const opener = this.api.host.describe({}) + const opener = this.remote.settings.canOpenAgentPresetDirectory() const roster = await beginRosterRead(this.remote, this.store) // A refused describe leaves the reveal-the-path path, which needs no opener. const described = await opener.catch(() => undefined) if (roster === undefined) return const { presets, authorable } = roster - const hasDocument = described?.result.ok === true && described.result.value.canOpenPath + const hasDocument = described?.ok === true && described.value if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) @@ -296,13 +294,13 @@ export class AgentPresetSectionController { */ async openLocation(id: string): Promise { try { - const response = await this.api.agentPresets.openDocument({ agentPreset: id }) - if (!response.result.ok) { - this.set({ error: response.result.error.message }) + const result = await this.remote.settings.openAgentPresetDirectory(id) + if (!result.ok) { + this.set({ error: result.error.message }) return } - if (response.result.value.opened) return - const { path } = response.result.value + if (result.value.opened) return + const { path } = result.value this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) } catch (error) { this.set({ error: messageOf(error) }) @@ -350,7 +348,7 @@ export class AgentPresetSectionController { * @returns once the write settled and the roster was re-read. */ async makeDefault(id: string): Promise { - const failure = await writeDefaultPreset(this.api, id) + const failure = await writeDefaultPreset(this.remote, id) if (failure !== undefined) { this.set({ error: failure }) return diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 2cde10fc4e..c2488de4af 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -74,6 +74,7 @@ async function bench() { // The row reads `describe` to learn whether this browser may write at all, // and its default write is the one op this spec records. const settings = { + canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }), describe: () => Promise.resolve({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, @@ -82,6 +83,10 @@ async function bench() { calls.push(`settings:${JSON.stringify(patch)}`) return Promise.resolve({ ok: true as const, value: {} }) }, + openAgentPresetDirectory: (agentPreset: string) => { + calls.push(`openAgentPresetDirectory:${agentPreset}`) + return Promise.resolve({ ok: true as const, value: { opened: true as const } }) + }, } const remote = new TestRemote(ctx, { settings }) // The roster and the switch are the AgentPresets Remote namespace; the @@ -110,22 +115,7 @@ async function bench() { } ctx.provide('remote.agentPresets', agentPresets as never) Object.assign(remote, { agentPresets }) - ctx.provide('connection', { - api: { - host: { - describe: () => Promise.resolve({ - rpcId: 'r', - result: { ok: true as const, value: { canOpenPath: true } }, - }), - }, - agentPresets: { - openDocument: (payload: { agentPreset: string }) => { - calls.push(`openDocument:${payload.agentPreset}`) - return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } }) - }, - }, - }, - } as never) + ctx.provide('connection', { isLoopback: true } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } @@ -187,7 +177,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ]) }) @@ -257,7 +247,7 @@ describe('ui-agent-preset apply', () => { // one the roster re-read reflects, and the delete the section confirmed // is the one its remove() sees. expect(calls).toContain('copy:mine') - expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0) + expect(calls.filter(call => call === 'openAgentPresetDirectory:mine').length).toBeGreaterThan(0) expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2) }) diff --git a/packages/client/ui-agent-preset/tests/locales.client.spec.ts b/packages/client/ui-agent-preset/tests/locales.client.spec.ts index 02623e7d7b..1d8b40cc67 100644 --- a/packages/client/ui-agent-preset/tests/locales.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/locales.client.spec.ts @@ -8,7 +8,7 @@ const translate = (bundle: typeof en) => (key: keyof typeof en): string => bundl describe('preset display copy', () => { it.each([ ['standard', 'presetStandardName', 'presetStandardDescription'], - ['code', 'presetCodeName', 'presetCodeDescription'], + ['ptc', 'presetPtcName', 'presetPtcDescription'], ['minimal', 'presetMinimalName', 'presetMinimalDescription'], ['cordis', 'presetCordisName', 'presetCordisDescription'], ] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => { diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 8d925f4978..0b732c4cf5 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,8 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -42,60 +41,16 @@ interface FakeOptions { authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean - /** Reject `host.describe`, as a dead transport does. */ - throwDescribe?: boolean + /** Reject the opener capability read, as a dead transport does. */ + throwCapability?: boolean /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } -const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) -const fail = (message: string) => - Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } }) - const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) const remoteFail = (message: string) => Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) -/** - * The carried wire face: the desktop opener, the default write, and the opener - * capability the page joins onto the roster. - * @param defaultId - the preset a session with no choice gets. - * @param options - failure injection and call recording. - * @returns the fake client. - */ -function fakeApi( - defaultId: { id: string }, - options: FakeOptions = {}, -): SettingsWireFace & Pick { - const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } - return { - host: { - describe: () => (options.throwDescribe === true - ? Promise.reject(new Error('socket closed')) - : ok({ canOpenPath: options.hasDocument ?? true })), - }, - agentPresets: { - openDocument: (payload: { agentPreset: string }) => { - record('openDocument', payload) - if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) - if (options.failOpen !== undefined) return fail(options.failOpen) - return (options.hasDocument ?? true) - ? ok({ opened: true }) - : ok({ opened: false, path: `/presets/${payload.agentPreset}` }) - }, - }, - settings: { - update: (ns: string, patch: { default?: string }) => { - record('settings.update', { ns, patch }) - if (options.failSettings !== undefined) return remoteFail(options.failSettings) - /* v8 ignore next -- the controller only ever sets `default` */ - defaultId.id = patch.default ?? defaultId.id - return remoteOk({}) - }, - }, - } as unknown as SettingsWireFace & Pick -} - /** * The Remote namespace over an in-memory preset store: copies land, so the * roster the controller re-reads after a copy is the one the copy produced. @@ -108,7 +63,7 @@ function fakeRemote( presets: Map, defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): Pick { const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } return { agentPresets: { @@ -167,7 +122,30 @@ function fakeRemote( return await remoteOk(undefined) }, }, - } as unknown as Pick + settings: { + canOpenAgentPresetDirectory: () => { + record('canOpenAgentPresetDirectory', {}) + return options.throwCapability === true + ? Promise.reject(new Error('socket closed')) + : remoteOk(options.hasDocument ?? true) + }, + update: (ns: string, patch: { default?: string }) => { + record('settings.update', { ns, patch }) + if (options.failSettings !== undefined) return remoteFail(options.failSettings) + /* v8 ignore next -- the controller only ever sets `default` */ + defaultId.id = patch.default ?? defaultId.id + return remoteOk({}) + }, + openAgentPresetDirectory: (agentPreset: string) => { + record('openAgentPresetDirectory', { agentPreset }) + if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) + if (options.failOpen !== undefined) return remoteFail(options.failOpen) + return (options.hasDocument ?? true) + ? remoteOk({ opened: true }) + : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) + }, + }, + } as unknown as Pick } function seed(): Map { @@ -184,7 +162,6 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeApi(defaultId, wired), fakeRemote(presets, defaultId, wired), () => { rosterChanges += 1 }, ) @@ -199,11 +176,11 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft { describe('loading the roster', () => { it('still lists the roster when the opener capability cannot be read', async () => { - const { controller } = harness({ throwDescribe: true }) + const { controller } = harness({ throwCapability: true }) await controller.load() - // The two reads are independent: a refused `host.describe` costs the + // The two reads are independent: a refused capability query costs the // open-directory affordance, not the page. const state = controller.store.getSnapshot() expect(state.status).toBe('ready') @@ -406,7 +383,7 @@ describe('submitting a copy', () => { .toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' }) // A preset is its files from here on, so landing in them completes the // copy rather than following it. - expect(calls.find(call => call.method === 'openDocument')?.payload) + expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload) .toEqual({ agentPreset: 'my-copy' }) }) @@ -476,7 +453,7 @@ describe('the location action', () => { await controller.openLocation('mine') - expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' }) + expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload).toEqual({ agentPreset: 'mine' }) expect(controller.store.getSnapshot().revealedPaths).toEqual({}) }) @@ -580,13 +557,13 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { agentPresets: {}, settings: {}, host: {} } as unknown as SettingsWireFace & Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), deletePreset: () => Promise.reject(new Error('socket closed')), }, - } as unknown as Pick, + settings: {}, + } as unknown as Pick, ) broken.confirmDelete('mine') @@ -603,7 +580,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeApi(defaultId), fakeRemote(presets, defaultId)) + fakeRemote(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 90f61b4893..060cb0f386 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 7378631ca9..5e0e50e06a 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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 packages/client/ui-attachment/README.md -README.md: 3e9240f5f2f68dbc9f709ee7e720bb48053ba3b5 -README.zh.md: 0a450d2bc78f183847b945c73107c1466fb3ba0e +README.md: 9fa03432b43686dd55af494640708a8bf0983f9a +README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3e9240f5f2..9fa03432b4 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -54,7 +54,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal | diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 0a450d2bc7..48e467280b 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开 | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖拽邀请 portal | diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index d5a10b446b..68efaafcd5 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index c4de8f73a7..61554fe93f 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -4,8 +4,22 @@ import { ImageLightbox } from './ImageLightbox.tsx' import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' -/** Loads a session-authorized durable image URL. */ -export type ImageLoader = (attachment: ImageAttachmentRef) => Promise +/** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */ +export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { + peek?: (attachment: ImageAttachmentRef) => string | undefined +} + +/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ +export type MessageImageSpec = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + readonly url: string + readonly name?: string + readonly width?: number + readonly height?: number + } + } /** Message-image strings the owner resolves from its own locale namespace. */ export interface MessageImageLabels { @@ -28,11 +42,13 @@ export interface MessageImageLabels { * `object-fit: cover` — and never upscaled past the image's natural size. The * crop anchor keeps the top of very tall images and the left of very wide * ones, where the informative content usually starts. */ -function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } { - const natural = attachment.width / attachment.height +function singleFit( + dimensions: { readonly width: number; readonly height: number }, +): { width: number; height: number; objectPosition: string } { + const natural = dimensions.width / dimensions.height const ratio = Math.min(4, Math.max(0.25, natural)) const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } - const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height) + const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height) return { width: Math.max(1, Math.round(box.width * scale)), height: Math.max(1, Math.round(box.height * scale)), @@ -40,24 +56,36 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num } } +/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */ +function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined { + if ('attachment' in image) return image.attachment + return image.preview.width !== undefined && image.preview.height !== undefined + ? { width: image.preview.width, height: image.preview.height } + : undefined +} + /** * Compact history renderer with retryable loading and click-to-open original * preview. A lone image renders at its `singleFit` size; an image among - * several renders as a fixed 64px square tile. + * several renders as a fixed 64px square tile. The preview arm displays its + * local URL directly — no loader round-trip, no failure/retry surface. * - * @param props.attachment - the durable image reference to load and bound. - * @param props.load - session-authorized URL loader. + * @param props.image - the durable reference to load, or the local preview to display. + * @param props.load - session-authorized URL loader for the durable arm. * @param props.variant - `single` for a message's lone image, `tile` otherwise. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ attachment, load, variant, labels }: { - attachment: ImageAttachmentRef +export function MessageImage({ image, load, variant, labels }: { + image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' labels: MessageImageLabels }) { - const [src, setSrc] = useState(null) + const preview = 'preview' in image ? image.preview : undefined + const attachment = 'attachment' in image ? image.attachment : undefined + const [loaded, setLoaded] = useState(() => + attachment === undefined ? null : (load.peek?.(attachment) ?? null)) const [error, setError] = useState(false) const [open, setOpen] = useState(false) // Retry re-arms the one load effect below, so every attempt — first load or @@ -65,20 +93,30 @@ export function MessageImage({ attachment, load, variant, labels }: { const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) + const dimensions = useMemo(() => dimensionsOf(image), [image]) const fit = useMemo( - () => (variant === 'single' ? singleFit(attachment) : undefined), - [attachment, variant], + () => { + if (variant !== 'single') return undefined + // A preview whose intake probe has not resolved sizes as a square crop; + // the durable replacement restores the exact fit. + return dimensions === undefined + ? { width: 240, height: 240, objectPosition: 'center' } + : singleFit(dimensions) + }, + [dimensions, variant], ) useEffect(() => { + if (attachment === undefined) return let live = true setError(false) - setSrc(null) - void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + setLoaded(load.peek?.(attachment) ?? null) + void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } }, [attachment, load, attempt]) - const label = attachment.name ?? labels.image + const src = preview?.url ?? loaded + const label = (preview?.name ?? attachment?.name) ?? labels.image if (error) return return ( <> @@ -103,7 +141,7 @@ export function MessageImage({ attachment, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ export function ImageGallery({ images, load, align, labels }: { - images: readonly { attachment: ImageAttachmentRef }[] + images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' labels: MessageImageLabels @@ -113,7 +151,13 @@ export function ImageGallery({ images, load, align, labels }: { return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d7d37bfd97..76a720b983 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -47,9 +47,19 @@ const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_S const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory) describe('MessageImage', () => { + it('renders a cached URL on the first frame while refreshing it', () => { + const load = Object.assign(vi.fn(() => new Promise(() => {})), { + peek: vi.fn(() => 'blob:seeded'), + }) + const view = render() + expect(view.queryByText('图片加载中…')).toBeNull() + expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded') + expect(load).toHaveBeenCalledWith(attachment) + }) + it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -64,7 +74,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -74,7 +84,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -84,7 +94,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -96,7 +106,7 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -105,7 +115,7 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -114,7 +124,7 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -123,7 +133,7 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -131,7 +141,7 @@ describe('MessageImage', () => { it('keeps the tile variant on the failed-load retry control', async () => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe('tile') }) @@ -139,29 +149,74 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() }) }) +describe('MessageImage preview arm', () => { + it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { + const load = vi.fn() + const view = render( + , + ) + expect(load).not.toHaveBeenCalled() + const img = view.getByAltText('echo.png') as HTMLImageElement + expect(img.src).toContain('blob:echo') + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('120px') + }) + + it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { + const load = vi.fn() + const view = render( + , + ) + const img = view.getByAltText('图片') as HTMLImageElement + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('240px') + }) + + it('opens the lightbox from a preview thumbnail', () => { + const view = render( + '')} variant="tile" labels={labels} />, + ) + fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + }) +}) + describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - , + , ) expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + expect(view.getByAltText('echo.png')).toBeTruthy() }) it('renders a lone image large and several images as square tiles', () => { diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 33180942fe..b40b6d8664 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 699fb752bb..a38a72ef3e 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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 packages/client/ui-chat/README.md -README.md: 26d712f58726f13d390efa2e6fd42b38fb373a1f -README.zh.md: 53a98828ccfc9c4841ccc8b4d27d4df527af0ec4 +README.md: 5c7549da07e8101c6b08b4cad57afd5158980b7f +README.zh.md: a558bf94f5436eebdf035ca7507ecd25ec3c8d8a diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 26d712f587..5c7549da07 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,12 +8,13 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic. ## Table of Contents - [System prompt row](#system-prompt-row) - [Turn token usage](#turn-token-usage) +- [Turn Process Folding](#turn-process-folding) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -34,6 +35,13 @@ A completed Turn shows an expandable usage row only when the loaded window inclu ----- + +## Turn Process Folding + +Settings → General exposes a persisted `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed ([folding decision](../../../.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md), [ordering decision](../../../.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md)). + +----- + ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 53a98828cc..a558bf94f5 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,12 +8,13 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。 ## 目录 - [系统提示词行](#system-prompt-row) - [轮次 token 用量](#turn-token-usage) +- [轮次过程折叠](#turn-process-folding) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -34,6 +35,13 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真 ----- + +## 轮次过程折叠 + +「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的 `Normal` / `Compact` 对话显示偏好,默认使用 `Compact`。Normal 保持所有过程行可见且不渲染轮次过程控件。Compact 模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终正文边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终正文之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终正文的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 高度变化。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域 store 只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起([折叠决策](../../../.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md),[排序决策](../../../.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md))。 + +----- + ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index b29c675a08..992ea08102 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-workspace" ], "platform": "web" @@ -62,6 +63,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", @@ -70,6 +72,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" @@ -89,6 +92,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -98,12 +102,16 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts new file mode 100644 index 0000000000..00433288f9 --- /dev/null +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -0,0 +1,29 @@ +/** Chat transcript preferences stored in the Host user-settings document. */ + +import z from '@deepseek-ai/schemastery' + +/** Settings namespace owned by the Chat target. */ +export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' + +/** Field carrying the completed-Turn transcript presentation mode. */ +export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' + +/** Transcript presentation modes accepted at settings boundaries. */ +export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const + +/** Completed-Turn transcript presentation. */ +export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number] + +/** Default preserves the compact process disclosure introduced by Chat. */ +export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact' + +/** Durable Chat section shared by the Host schema and browser scope. */ +export interface ChatSettings { + /** Presentation mode for completed Turn process content. */ + transcriptView: TranscriptViewMode +} + +/** Durable Chat schema; also the wire envelope the browser scope validates against. */ +export const ChatSettingsSchema: z = z.object({ + [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), +}) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 3243c6c642..be061dff13 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,5 +1,7 @@ /** Register the Chat Conversation target, renderers, stats, and details surface. */ import type { Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -10,6 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected, @@ -24,7 +27,10 @@ import { StatsLine } from './chat/StatsLine.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { DetailsPanel } from './details/DetailsPanel.tsx' import { en, NS, zh } from './locale.ts' +import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' +import { TranscriptViewPolicy } from './transcript-view.ts' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { hooks: { @@ -41,7 +47,8 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', + 'slots', 'sessions', 'uiSession', 'uiConversation', 'layout', 'locale', + 'settingsScope', 'remote', 'remote.session', ] /** @@ -73,6 +80,20 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(NS) const chatStore = createChatStore() const chatScrollPositions = new Map() + const transcriptView = new TranscriptViewPolicy( + ctx.settingsScope.bind({ namespace: CHAT_SETTINGS_NAMESPACE }), + ) + + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'transcript-view', + order: 12, + locale: NS, + inject: (): TranscriptViewRowInjected => ({ + hooks: { transcriptView: transcriptView.mode }, + setTranscriptView: (mode) => { transcriptView.setMode(mode) }, + }), + }, TranscriptViewRow)) ctx.slots.inject('conversation.view', () => { const disposeView = ctx.slots.register({ @@ -90,17 +111,24 @@ export function apply(ctx: Context): void { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) return { + hooks: { transcriptView: transcriptView.mode }, openDetails: (target) => { actions.select(target) ctx.layout.openDetails() }, fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), - openFile: (path) => { + openFile: async (path) => { const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) + const result = await ctx.remote.session.openWorkspacePath({ + path: resolveWorkspacePath(cwd, path), + }) + if (!result.ok) throw new Error(`path open failed: ${result.error.message}`) }, loadOlder: () => { void session.loadOlder() }, - loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), + loadImage: Object.assign( + (attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment), + { peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) }, + ), chatScroll: { save: (position) => { if (position === null) chatScrollPositions.delete(sessionId) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index 8c1858fe94..8d7c41ac85 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -42,6 +42,13 @@ padding-left: var(--dsh-table-lead); } +/* hidden="until-found" keeps a zero-height reasoning box in flex layout. + Cancel the one gap that box would otherwise leave before the visible reply; + visible Assistant blocks retain the ordinary 16px rhythm. */ +.body > [data-turn-process-inline][hidden] { + margin-bottom: -16px; +} + /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed size like the small/code token variants — 11px is dense secondary text that would fall to an illegible 9px at the 12px floor. */ diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 8838725f75..c517f9d0c2 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -6,6 +6,7 @@ import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' +import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { @@ -15,6 +16,10 @@ export interface AssistantMarkdownProps { interrupted?: boolean | undefined /** Render consecutive image blocks through the attachment slot. */ renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + /** Hide reasoning that belongs to the Turn-level process disclosure. */ + reasoningHidden?: boolean | undefined + /** Reveal the owning Turn-level process disclosure. */ + revealProcess?: (() => void) | undefined /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -23,7 +28,8 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, renderMessageImages, mentions, t, + blocks, streaming, interrupted, renderMessageImages, + reasoningHidden = false, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -53,7 +59,15 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) break case 'reasoning': - rendered.push() + rendered.push( + , + ) break case 'image': { // Consecutive image blocks share one gallery so several images tile @@ -102,3 +116,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) }) + +function ProcessReasoning({ hidden, reveal, children }: { + hidden: boolean + reveal?: (() => void) | undefined + children: ReactNode +}) { + const ref = useSearchableHidden(hidden, reveal ?? NOOP) + return
{children}
+} + +const NOOP = (): void => {} diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 850036f0cd..399897a9b5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -1,10 +1,10 @@ -import { memo, useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, openFile, renderMessageImages, fileMentions, t, + node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t, }: ChatNodeViewProps<'assistant-step'>) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -20,12 +20,20 @@ export const AssistantNodeView = memo(function AssistantNodeView({ () => owner === undefined ? undefined : fileMentions(owner), [fileMentions, owner], ) + const reasoningHidden = turnProcess !== undefined + && turnProcess.foldable + && turnProcess.spec.answerStep === data.step + && turnProcess.spec.inlineReasoning + && !turnProcess.open + const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess]) return ( diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 7568f767ff..00eb9fd70d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -1,12 +1,23 @@ -import { memo, useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatNodeStore } from '../contract/snapshot.ts' +import { + decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration, + type TurnProcessSpec, +} from '../contract/turn-process.ts' +import { storedTurnProcessEntry } from '../stores.ts' +import { useSearchableHidden } from './searchable-hidden.ts' import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string + readonly historyIncomplete: boolean + readonly compactTranscript: boolean readonly useChat: ChatViewSlotProps['useChat'] + readonly useStore: ChatViewSlotProps['useStore'] + readonly actions: ChatViewSlotProps['actions'] readonly renderSlot: ChatViewSlotProps['renderSlot'] readonly t: ChatViewSlotProps['t'] } @@ -15,13 +26,153 @@ type RoutedChatNodeOwner = { [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode } }[ChatNode['kind']] -/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ +const EMPTY_PROCESS_KEYS: readonly string[] = [] + +interface TurnProcessLayout { + readonly hasExternalProcess: boolean + readonly compactAnswer: boolean +} + +function turnProcessOpeningHumanAnchor( + keys: readonly string[], + nodes: ChatNodeStore, + spec: TurnProcessSpec, +): number | undefined { + let anchor: number | undefined + for (const key of keys) { + const node = nodes.get(key) as ChatNode | undefined + if ((node?.kind === 'user' || node?.kind === 'steering') + && node.anchorSeq < spec.controlAnchorSeq) { + anchor = Math.min(anchor ?? node.anchorSeq, node.anchorSeq) + } + } + return anchor +} + +/** Derive disclosure facts from one content-revisioned Turn index. */ +function turnProcessLayout( + keys: readonly string[], + nodes: ChatNodeStore, + spec: TurnProcessSpec, +): TurnProcessLayout { + let hasExternalProcess = false + let compactAnswer = true + const openingHumanAnchor = turnProcessOpeningHumanAnchor(keys, nodes, spec) + for (const key of keys) { + const node = nodes.get(key) as ChatNode | undefined + if (node === undefined || node.kind === 'turn-process') continue + if ((node.kind === 'user' || node.kind === 'steering') + && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor) + && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { + compactAnswer = false + } + if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + || node.anchorSeq < spec.processStartSeq + || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue + if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { + hasExternalProcess = true + } + } + return { hasExternalProcess, compactAnswer } +} + +/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, useChat, renderSlot, t, + nodeKey, historyIncomplete, compactTranscript, + selectedCallId, cwd, openFile, inspectCall, forkAt, + renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) + const processSignature = useChat((snapshot) => { + const current = snapshot.nodes.get(nodeKey) + const location = current?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get('turn-process') + : undefined + }) + const processSpec = useMemo( + () => processSignature === undefined ? undefined : decodeTurnProcess(processSignature), + [processSignature], + ) + const nodeStore = useChat(snapshot => snapshot.nodes) + const processLayoutKeys = useChat((snapshot) => { + if (!compactTranscript || historyIncomplete || processSpec === undefined) return EMPTY_PROCESS_KEYS + const current = snapshot.nodes.get(nodeKey) as ChatNode | undefined + const location = current?.location + if (current === undefined + || (location?.kind !== 'turn' && location?.kind !== 'step') + || location.turn.status !== 'closed' + || location.turn.turn !== processSpec.turn) return EMPTY_PROCESS_KEYS + const ownsLayout = current.kind === 'turn-process' + || (current.kind === 'assistant-step' && current.data.step === processSpec.answerStep) + return ownsLayout ? snapshot.locations.getTurn(processSpec.turn) : EMPTY_PROCESS_KEYS + }) + const processLayout = useMemo( + () => processSpec === undefined || processLayoutKeys.length === 0 + ? undefined + : turnProcessLayout(processLayoutKeys, nodeStore, processSpec), + [nodeStore, processLayoutKeys, processSpec], + ) + const processGeneration = useMemo( + () => processSpec === undefined ? undefined : turnProcessGeneration(processSpec), + [processSpec], + ) + const storedEntry = useStore(state => processSpec === undefined + ? undefined + : storedTurnProcessEntry(state, processSpec.turn)) + const processEntry = storedEntry?.generation === processGeneration ? storedEntry : undefined + const processOpen = processEntry !== undefined + const setOpen = useCallback((open: boolean) => { + if (processGeneration !== undefined && processSpec !== undefined) { + actions.setTurnProcessOpen(processSpec.turn, processGeneration, open) + } + }, [actions, processGeneration, processSpec]) const routedNode = node as ChatNode | undefined + const sameTurn = routedNode !== undefined + && processSpec !== undefined + && (routedNode.location.kind === 'turn' || routedNode.location.kind === 'step') + && routedNode.location.turn.turn === processSpec.turn + const turnClosed = sameTurn + && routedNode.location.turn.status === 'closed' + const processWindowReady = processSpec !== undefined + && compactTranscript + && processSpec.answerAnchorSeq !== null + && turnClosed + && !historyIncomplete + const processMember = sameTurn + && processWindowReady + && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) + && routedNode.anchorSeq >= processSpec.processStartSeq + && routedNode.anchorSeq < processSpec.answerAnchorSeq + const processAnswer = sameTurn + && processWindowReady + && routedNode.kind === 'assistant-step' + && routedNode.data.step === processSpec.answerStep + const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer + const foldable = processWindowReady + && (processMember || (ownsDisclosure + && ((processLayout?.hasExternalProcess ?? false) || processSpec.inlineReasoning))) + const turnProcess = useMemo(() => processGeneration === undefined || processSpec === undefined + ? undefined + : { + spec: processSpec, + foldable, + open: processOpen, + setOpen, + }, [ + foldable, processGeneration, processOpen, processSpec, setOpen, + ]) + const controllerInactive = routedNode?.kind === 'turn-process' + && !foldable + const compactAnswer = processAnswer + && foldable + && processLayout?.compactAnswer === true + && !processOpen + const processHidden = controllerInactive || (foldable && processMember && !processOpen) + const revealProcess = useCallback(() => { + if (processMember) setOpen(true) + }, [processMember, setOpen]) + const wrapperRef = useSearchableHidden(processHidden, revealProcess) const owner = useMemo(() => node === undefined ? null : { @@ -32,8 +183,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ forkAt, renderMessageImages, fileMentions, + turnProcess, }, [ - node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions, + node, selectedCallId, cwd, openFile, inspectCall, forkAt, + renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null const location = routedNode.location @@ -46,11 +199,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner return (
{renderSlot('conversation.chat.node', routedOwner, { entryKey: routedNode.kind, diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 502ae295be..0dbb71f541 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -41,7 +41,14 @@ margin: 0 auto; display: flex; flex-direction: column; - gap: 16px; +} + +/* `hidden="until-found"` retains a zero-height box so browser find can reveal + its subtree. Direct Chat Node Seats and auxiliary rows share one flow; + hidden and empty Seats do not contribute spacing. */ +.column > :not([hidden]):not(.flowItem:empty) + ~ :not([hidden]):not(.flowItem:empty) { + margin-top: var(--dsh-chat-flow-gap, 16px); } /* Settled-flow identity boundary. It is neutral until it becomes the natural @@ -50,6 +57,12 @@ min-width: 0; } +/* A closed process reads as one summary immediately followed by its answer. + Expanded process rows return to the ordinary 16px rhythm. */ +.flowItem[data-turn-process-answer] { + --dsh-chat-flow-gap: 8px; +} + /* A keyed renderer may intentionally decline its row after dispatch (the completed-turn tail does this when it owns neither actions nor extensions). An empty flex item must not consume the column gap. */ diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ade617c95e..b5f0313f15 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,8 +7,8 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { TurnNavigationItem } from '../contract/snapshot.ts' -import { PendingSteeringBubble } from './MessageItem.tsx' +import type { ChatSnapshot, TurnNavigationItem } from '../contract/snapshot.ts' +import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' import { formatRunDuration } from './message-chrome.ts' @@ -30,7 +30,7 @@ interface PagingAnchor { /** Find an already-rendered row without interpolating a selector. */ function anchorElement(list: HTMLElement, key: string): HTMLElement | null { - for (const row of list.querySelectorAll('[data-chat-anchor-key]')) { + for (const row of list.querySelectorAll('[data-chat-anchor-key]:not([hidden])')) { if (row.dataset.chatAnchorKey === key) return row } return null @@ -87,7 +87,9 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | if (row !== null && list.contains(row)) return row } } - const rows = list.querySelectorAll('[data-chat-flow] > [data-chat-flow-key]:not(:empty)') + const rows = list.querySelectorAll( + '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])', + ) let low = 0 let high = rows.length while (low < high) { @@ -124,10 +126,37 @@ function isFolderOpenPath(path: string): boolean { return path === '.' } +/** + * Prompt-RPC identities already rendered by durable material: user/steering + * node sources plus queue occurrences. A submission echo whose identity + * appears here is hidden in the same render, so the echo→durable swap is + * atomic — no duplicate, no gap — regardless of when the echo leaves the + * session snapshot. + */ +function observedRpcIds( + order: readonly string[], + nodes: ChatSnapshot['nodes'], + queue: readonly { readonly rpcId?: string }[], +): ReadonlySet { + const observed = new Set() + for (const key of order) { + const node = nodes.get(key) + if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue + const source = (node.data as { readonly source?: unknown }).source as + | { readonly kind?: unknown; readonly rpcId?: unknown } + | undefined + if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + } + for (const item of queue) { + if (item.rpcId !== undefined) observed.add(item.rpcId) + } + return observed +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { - if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time + if (turn.status === 'open') latest = turn.start?.time ?? null } return latest } @@ -173,8 +202,8 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, - fileMentions, t, + useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, + fileMentions, useTranscriptView, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) @@ -192,6 +221,7 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const compactTranscript = useTranscriptView(mode => mode === 'compact') const inspectCall = useCallback((callId: string) => { openView('trajectory', callId) }, [openView]) @@ -234,6 +264,15 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + // Submission echoes still awaiting their durable counterpart. `order` is the + // recompute trigger: durable user material always arrives as an append, and + // every append replaces the order array. + const visibleSubmissions = useMemo(() => { + if (pendingSubmissions.length === 0) return pendingSubmissions + const observed = observedRpcIds(order, nodeStore, inbox) + return pendingSubmissions.filter(submission => !observed.has(submission.requestId)) + }, [pendingSubmissions, order, nodeStore, inbox]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], @@ -242,8 +281,10 @@ export function ChatView({ const listRef = useRef(null) const columnRef = useRef(null) - const atBottomRef = useRef(true) - const [atBottom, setAtBottom] = useState(true) + // A saved position starts disarmed; the first layout effect synchronously + // restores it and normalizes a floor-clamped position back to following. + const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null) + const atBottomRef = useRef(atBottom) const [activeTurn, setActiveTurn] = useState( () => turnNavigationItems.at(-1)?.turn ?? null, ) @@ -256,6 +297,7 @@ export function ChatView({ const openedRef = useRef(false) const lastKeyRef = useRef(null) const lastSteeringIdRef = useRef(null) + const lastSubmissionIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (which would snap inertial * scrolls the rest of the way to the floor). */ @@ -266,7 +308,8 @@ export function ChatView({ const lastKey = order.at(-1) ?? null const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` + const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` const syncActiveTurn = useCallback((): void => { const local = listRef.current @@ -358,6 +401,7 @@ export function ChatView({ firstSeqRef.current = firstSeq lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -374,6 +418,7 @@ export function ChatView({ /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -382,13 +427,15 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current + const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -498,7 +545,8 @@ export function ChatView({ loadOlder() } - const navigateToTurn = (item: TurnNavigationItem): void => { + // Identity feeds the memoized rail; a fresh closure per render would defeat it. + const navigateToTurn = useCallback((item: TurnNavigationItem): void => { const local = listRef.current if (local === null) return const row = anchorElement(local, item.anchorKey) @@ -519,7 +567,7 @@ export function ChatView({ const position = isAtBottom ? null : scrollPosition(local, el) if (isAtBottom) chatScroll.save(null) else if (position !== null) chatScroll.save(position) - } + }, [loadingOlder, chatScroll]) return (
@@ -548,7 +596,11 @@ export function ChatView({ ))} + {visibleSubmissions.map(submission => ( + + ))}
{!atBottom && (
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index aa3395bde1..467e486ecb 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,5 +1,7 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' +import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' @@ -146,7 +148,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -154,15 +156,25 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */ + echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Local submission-echo previews replacing the content-derived image group. */ + previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images, rest } = contentParts(content) + const { text, images: contentImages, rest } = contentParts(content) + const images = previewImages ?? contentImages const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( -
+
{renderMessageImages({ images, align: 'end' })} {showBubble &&
@@ -209,6 +221,54 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: { ) } +/** + * Render one local submission echo with the exact visual language of the + * durable user node that replaces it: draft text plus object-URL previews, + * visible from the submit click until the durable `user/message` (or its + * queue occurrence) renders. + * @param props - the session snapshot's pending submission and render seats. + * @returns the echoed user bubble. + */ +export function PendingSubmissionBubble({ submission, renderMessageImages, t }: { + submission: PendingSubmission + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const content = useMemo( + () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), + [submission.text], + ) + const previewImages = useMemo( + () => submission.images.map(image => ({ + preview: { + url: image.previewUrl, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.width === undefined ? {} : { width: image.width }), + ...(image.height === undefined ? {} : { height: image.height }), + }, + })), + [submission.images], + ) + return ( + ( + + )} + /> + ) +} + /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ node, renderMessageImages, t, diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index b2533e533c..55818f85cd 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,5 +1,5 @@ import { - useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, + memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnNavigationItem } from '../contract/snapshot.ts' @@ -53,8 +53,7 @@ function itemAtPointer( return items[Math.round(ratio * (items.length - 1))] } -/** Compact rail of the currently loaded Turns with hover and focus previews. */ -export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { +function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) const previewId = useId() if (items.length < 2) return null @@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
) } + +/** + * Compact rail of the currently loaded Turns with hover and focus previews. + * + * Memoized because it renders two host elements per loaded Turn while the + * enclosing view re-renders on every streaming delta: without the guard a long + * session rebuilds hundreds of marks per commit for a rail that only changes + * when a Turn is added, removed, or becomes active. Its props must therefore + * stay referentially stable across those commits. + */ +export const TurnNavigator = memo(TurnNavigatorRail) diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css new file mode 100644 index 0000000000..d0e0cd92a0 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css @@ -0,0 +1,48 @@ +.root { + box-sizing: border-box; + display: flex; + align-items: center; + width: 100%; + min-width: 0; + height: 33px; + padding: 0 0 8px; + border: none; + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: none; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + text-align: left; +} + +.root:not([data-open]) { + margin-bottom: 8px; +} + +.chevron { + flex: none; + width: 16px; + height: 16px; + margin-left: 6px; + color: var(--dsw-alias-label-tertiary); + transform: rotate(-90deg); + transition: transform 100ms ease; +} + +.root[data-open] .chevron { + transform: rotate(0deg); +} + +.label { + min-width: 0; + overflow: hidden; + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } +} diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx new file mode 100644 index 0000000000..f9210e84db --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -0,0 +1,60 @@ +import { memo } from 'react' +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatNodeViewProps } from '../contract/slots.ts' +import css from './TurnProcessNodeView.module.css' + +/** Turn-level process disclosure controller. */ +export const TurnProcessNodeView = memo(function TurnProcessNodeView({ + node, turnProcess, t, +}: ChatNodeViewProps<'turn-process'>) { + if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state') + if (!turnProcess.foldable) return null + const open = turnProcess.open + const labels: string[] = [] + if (node.data.toolCallCount > 0) { + labels.push(t( + node.data.toolCallCount === 1 + ? 'message.turnProcess.toolCalls.one' + : 'message.turnProcess.toolCalls.other', + { count: node.data.toolCallCount }, + )) + } + if (node.data.messageCount > 0) { + labels.push(t( + node.data.messageCount === 1 + ? 'message.turnProcess.messages.one' + : 'message.turnProcess.messages.other', + { count: node.data.messageCount }, + )) + } + if (node.data.subagentCount > 0) { + labels.push(t( + node.data.subagentCount === 1 + ? 'message.turnProcess.subagents.one' + : 'message.turnProcess.subagents.other', + { count: node.data.subagentCount }, + )) + } + const label = labels.length === 0 + ? t('message.turnProcess.thoughtForAWhile') + : labels.join(t('message.turnProcess.separator')) + return ( + + ) +}) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index 748d75a364..e58ec4a9ad 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -6,8 +6,9 @@ import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from './MessageItem.tsx' -import { TurnTailNodeView } from './TurnTailNodeView.tsx' import { SystemPromptNodeView } from './SystemPromptRow.tsx' +import { TurnProcessNodeView } from './TurnProcessNodeView.tsx' +import { TurnTailNodeView } from './TurnTailNodeView.tsx' /** * Register this package's business renderers behind the keyed Chat Node seat. @@ -40,6 +41,8 @@ export function registerChatNodeRenderers(ctx: Context): void { { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-process', locale: NS }, TurnProcessNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'turn-tail', diff --git a/packages/client/ui-chat/src/client/chat/searchable-hidden.ts b/packages/client/ui-chat/src/client/chat/searchable-hidden.ts new file mode 100644 index 0000000000..5a0f47c82e --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/searchable-hidden.ts @@ -0,0 +1,31 @@ +import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react' + +/** + * Apply searchable hidden state without unmounting a stable subtree. + * @param hidden - whether the subtree is currently hidden. + * @param reveal - callback for browser find's `beforematch` reveal. + * @returns ref for the stable subtree root. + */ +export function useSearchableHidden( + hidden: boolean, + reveal: () => void, +): RefObject { + const ref = useRef(null) + useLayoutEffect(() => { + const element = ref.current + if (element === null) return + if (hidden && element.contains(element.ownerDocument.activeElement)) { + reveal() + return + } + if (hidden) element.setAttribute('hidden', 'until-found') + else element.removeAttribute('hidden') + }, [hidden, reveal]) + useEffect(() => { + const element = ref.current + if (element === null) return + element.addEventListener('beforematch', reveal) + return () => { element.removeEventListener('beforematch', reveal) } + }, [reveal]) + return ref +} diff --git a/packages/client/ui-chat/src/client/contract/assistant-content.ts b/packages/client/ui-chat/src/client/contract/assistant-content.ts new file mode 100644 index 0000000000..e06c025873 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/assistant-content.ts @@ -0,0 +1,15 @@ +import type { AssistantBlock } from './snapshot.ts' + +/** + * Test whether Assistant blocks contain a user-facing reply rather than only + * reasoning or Tool-call protocol material. + * @param blocks - Assistant content blocks. + * @returns whether the blocks contain visible reply content. + */ +export function hasAssistantReplyContent(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'reasoning' || block.kind === 'tool-call') return false + if (block.kind === 'text') return block.text.trim() !== '' + return true + }) +} diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index db6f61433f..d04bdfd90e 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -97,6 +97,19 @@ export interface TurnTailChatData { readonly tokenUsage?: TurnTokenUsage } +/** Turn-level process disclosure projected before the finalized answer. */ +export interface TurnProcessChatData { + readonly turn: number + readonly controlAnchorSeq: number + readonly processStartSeq: number + readonly answerAnchorSeq: number | null + readonly answerStep: number | null + readonly inlineReasoning: boolean + readonly messageCount: number + readonly toolCallCount: number + readonly subagentCount: number +} + /** * Test whether a Tool root has settled. * @param block - Tool root lifecycle value. diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 49c2cb841b..307d8f703b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,19 +1,21 @@ /** Chat-owned Slot declarations and composed component props. */ -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, + ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' import type { ToolCallId, SelectionTarget } from './store.ts' import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' +import type { TurnProcessSpec } from './turn-process.ts' +import type { TranscriptViewMode } from '../../chat-settings.ts' /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook @@ -66,6 +68,16 @@ export interface ChatNodeOwnerProps { forkAt: (seq: number) => void renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined + /** Turn-process state when this Node belongs to a projected Turn. */ + turnProcess?: TurnProcessOwnerProps | undefined +} + +/** Shared presentation state for one Turn-process answer generation. */ +export interface TurnProcessOwnerProps { + readonly spec: TurnProcessSpec + readonly foldable: boolean + readonly open: boolean + setOpen(open: boolean): void } /** Full props of one keyed Chat renderer. */ @@ -99,10 +111,14 @@ export interface ChatScrollPosition { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { + hooks: { + /** Persisted completed-Turn transcript presentation. */ + transcriptView: SnapshotStore + } openDetails: (target: SelectionTarget) => void openFile: (path: string) => Promise loadOlder: () => void - loadImage: (attachment: ImageAttachmentRef) => Promise + loadImage: MessageImageLoader chatScroll: { save: (position: ChatScrollPosition | null) => void read: () => ChatScrollPosition | null diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts index 94d2d914a7..f5f22a4f85 100644 --- a/packages/client/ui-chat/src/client/contract/store.ts +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -1,5 +1,7 @@ /** Chat-owned selection state shared by the transcript and details panel. */ +import type { TurnProcessGeneration } from './turn-process.ts' + /** Tool call identity as carried by Chat nodes. */ export type ToolCallId = string @@ -11,7 +13,14 @@ export interface SelectionTarget { toolName?: string } +/** One manually expanded Turn answer generation. */ +export interface TurnProcessViewEntry { + readonly turn: number + readonly generation: TurnProcessGeneration +} + /** Per-Session state shared only by the Chat view and details surface. */ export interface ChatStoreState { selection: SelectionTarget | null + turnProcesses: TurnProcessViewEntry[] } diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts new file mode 100644 index 0000000000..791830dead --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -0,0 +1,100 @@ +import type { ChatNode } from './chat-nodes.ts' + +/** Turn-local process window encoded as a reference-stable Location-data scalar. */ +export type TurnProcessSignature = string + +/** Stable identity of one finalized answer generation, independent of its exact ordering anchor. */ +export type TurnProcessGeneration = string + +/** Current process range and finalized answer boundary derived from one Turn. */ +export interface TurnProcessSpec { + readonly turn: number + /** Stable control-node anchor source, including currently ineligible evidence. */ + readonly controlAnchorSeq: number + readonly processStartSeq: number + readonly answerAnchorSeq: number | null + readonly answerStep: number | null + readonly inlineReasoning: boolean + /** Reply-bearing durable Assistant messages before the final answer. */ + readonly messageCount: number + /** Durable non-subagent Tool calls recorded by this Turn. */ + readonly toolCallCount: number + /** Tool calls whose configured name identifies a subagent delegation. */ + readonly subagentCount: number +} + +const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ + 'system-prompt', + 'user', + 'steering', + 'turn-process', + 'turn-error', + 'turn-max-tokens', + 'turn-tail', +] as const satisfies readonly ChatNode['kind'][] + +/** Chat Node kinds that remain independent of a Turn's process disclosure. */ +export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet = new Set( + TURN_PROCESS_INDEPENDENT_KIND_LIST, +) + +/** + * Identify one finalized answer generation without using its ordering anchor. + * @param spec - current Turn process specification. + * @returns stable identity until the finalized answer Step is withdrawn or replaced. + */ +export function turnProcessGeneration(spec: TurnProcessSpec): TurnProcessGeneration { + return `${String(spec.turn)}|${spec.answerStep === null ? '' : String(spec.answerStep)}` +} + +/** + * Encode one process specification as a primitive Location-data value. + * @param spec - current Turn process specification. + * @returns reference-stable scalar for equal specifications. + */ +export function encodeTurnProcess(spec: TurnProcessSpec): TurnProcessSignature { + return [ + spec.turn, + spec.controlAnchorSeq, + spec.processStartSeq, + spec.answerAnchorSeq ?? '', + spec.answerStep ?? '', + spec.inlineReasoning ? 1 : 0, + spec.messageCount, + spec.toolCallCount, + spec.subagentCount, + ].join('|') +} + +/** + * Decode a same-process signature produced by {@link encodeTurnProcess}. + * @param signature - encoded Turn process value. + * @returns decoded process specification. + */ +export function decodeTurnProcess(signature: TurnProcessSignature): TurnProcessSpec { + const [ + turn, controlAnchorSeq, processStartSeq, answerAnchorSeq, answerStep, inlineReasoning, + messageCount, toolCallCount, subagentCount, + ] = signature.split('|') + return { + turn: Number(turn), + controlAnchorSeq: Number(controlAnchorSeq), + processStartSeq: Number(processStartSeq), + answerAnchorSeq: answerAnchorSeq === '' ? null : Number(answerAnchorSeq), + answerStep: answerStep === '' ? null : Number(answerStep), + inlineReasoning: inlineReasoning === '1', + messageCount: Number(messageCount), + toolCallCount: Number(toolCallCount), + subagentCount: Number(subagentCount), + } +} + +/** + * Recognize the shipped subagent delegation name and its configured variants. + * Control tools use distinct names such as `send_message` and `list_agents`. + * @param name - durable Tool-call name. + * @returns whether the call creates or forks a subagent. + */ +export function isSubagentDelegationTool(name: string): boolean { + return name === 'subagent' || name.startsWith('subagent_') +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index ba25bf6464..74fa8c6ac8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -9,6 +9,7 @@ import type { ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode, LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem, } from '../contract/snapshot.ts' +import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels } from './event-projection.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' @@ -192,10 +193,101 @@ function locationCoordinates(location: ConversationLocation): { turn?: number; s return {} } -function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] { - return nodes - .filter(node => node.visibility === 'visible') - .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) +interface TurnProcessPresentation { + readonly control?: ChatNode<'turn-process'> + readonly openingHumanAnchor?: number + readonly earliestProcessAnchor?: number +} + +function turnProcessPresentations( + nodes: readonly ChatConversationViewNode[], +): ReadonlyMap { + const presentations = new Map() + for (const raw of nodes) { + const node = raw as ChatNode + if (node.kind === 'turn-process') { + presentations.set(node.data.turn, { ...presentations.get(node.data.turn), control: node }) + } + } + for (const raw of nodes) { + const node = raw as ChatNode + const location = node.location + if (location.kind !== 'turn' && location.kind !== 'step') continue + const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} + if ((node.kind === 'user' || node.kind === 'steering') + && node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) { + presentations.set(location.turn.turn, { + ...current, + openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq), + }) + continue + } + if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue + presentations.set(location.turn.turn, { + ...current, + earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq), + }) + } + return presentations +} + +interface PresentationPosition { + readonly anchor: number + readonly rank: number + readonly originalAnchor: number +} + +function presentationPosition( + raw: ChatConversationViewNode, + presentations: ReadonlyMap, +): PresentationPosition { + const node = raw as ChatNode + const location = node.location + if (location.kind !== 'turn' && location.kind !== 'step') { + return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq } + } + const presentation = presentations.get(location.turn.turn) + if (presentation === undefined) { + return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq } + } + const openingHumanAnchor = presentation.openingHumanAnchor + if (openingHumanAnchor !== undefined + && node.anchorSeq < openingHumanAnchor + && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) { + return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq } + } + if (presentation.control !== undefined && node.key === presentation.control.key) { + return openingHumanAnchor === undefined + ? { + anchor: presentation.earliestProcessAnchor ?? node.anchorSeq, + rank: -1, + originalAnchor: node.anchorSeq, + } + : { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq } + } + return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq } +} + +/** + * Order visible Chat Nodes without changing existing relative order as process + * eligibility changes. Opening human input precedes process candidates, while + * each synthetic process control sits between them. + * @param nodes - currently materialized Chat Nodes. + * @returns visible Nodes in presentation order. + */ +export function orderedVisibleChatNodes( + nodes: readonly ChatConversationViewNode[], +): ChatConversationViewNode[] { + const visible = nodes.filter(node => node.visibility === 'visible') + const presentations = turnProcessPresentations(visible) + return visible.sort((left, right) => { + const leftPosition = presentationPosition(left, presentations) + const rightPosition = presentationPosition(right, presentations) + return leftPosition.anchor - rightPosition.anchor + || leftPosition.rank - rightPosition.rank + || leftPosition.originalAnchor - rightPosition.originalAnchor + || left.key.localeCompare(right.key) + }) } function referenceMessageSeq(node: ChatConversationViewNode): number | undefined { @@ -556,7 +648,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + this.order = orderedVisibleChatNodes(nodes).map(node => node.key) this.locations.rebuild(this.order, this.store) this.navigation.rebuild(input.timeline, this.locations, this.store) this.timeline = input.timeline @@ -573,6 +665,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key) this.order = sameReferences(this.order, next) ? this.order : next this.locations.rebuild(this.order, this.store) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/common.ts b/packages/client/ui-chat/src/client/conversation-nodes/common.ts index fd63c47cdd..5d0b123163 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/common.ts @@ -14,6 +14,7 @@ import type { export const CHAT_SYNTHETIC_SEQ_OFFSETS = { interruptedAssistant: -0.9, interruptedFollowup: -0.8, + processControl: -0.1, maxTokensNotice: 0.05, finalizedFollowup: 0.1, } as const diff --git a/packages/client/ui-chat/src/client/conversation-nodes/register.ts b/packages/client/ui-chat/src/client/conversation-nodes/register.ts index d40fb1b00a..0754aeb6a0 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/register.ts @@ -11,6 +11,7 @@ import { registerRetryConversationNode } from './retry.ts' import { registerToolConversationNode } from './tool.ts' import { registerTurnErrorConversationNode } from './turn-error.ts' import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts' +import { registerTurnProcess } from './turn-process.ts' import { registerTurnTailConversationNode } from './turn-tail.ts' /** @@ -22,6 +23,7 @@ export function registerConversationNodes(ctx: Context): void { registerMessageConversationNode(ctx) registerRequestPromptConversationNode(ctx) registerAssistantConversationNode(ctx) + registerTurnProcess(ctx) registerToolConversationNode(ctx) registerCommandConversationNode(ctx) registerCompactionConversationNode(ctx) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index c7f25cf6db..2cf518924b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeDefinition, RequestPromptInspector, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNode } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { @@ -33,6 +34,19 @@ function requestPromptAnchor( : match.location.step.start?.seq ?? match.event.seq } +/** Keep an already rendered prompt at its page-lifetime presentation anchor. */ +function stableRequestPromptAnchor( + context: ConversationNodeContext, + match: ConversationMatch, + previous: Readonly | undefined, + isInitial: boolean, +): number { + const current = context.current.get('chat') as ChatNode | null | undefined + return current?.kind === 'system-prompt' + ? current.anchorSeq + : requestPromptAnchor(match, previous, isInitial) +} + /** * Request-header prompt Definition for the Chat target. * @param inspect - the shared prompt interpretation, supplied by the @@ -46,7 +60,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver match: event => event.type === 'request/header' ? { id: String(event.seq), role: 'start' } : null, - start: (_context, match, reader) => { + start: (context, match, reader) => { if (match.event.type !== 'request/header') { throw new Error('request-prompt start requires request/header') } @@ -57,7 +71,12 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver const inspection = inspect(previous?.prompt, match.event) const change = inspection.change?.kind return { - anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'), + anchorSeq: stableRequestPromptAnchor( + context, + match, + previous, + match.event.data.reason === 'initial', + ), showsPrompt: previous === undefined || match.event.data.reason !== 'change' || match.event.data.startsSeries === true diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts new file mode 100644 index 0000000000..fd04874615 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -0,0 +1,283 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types' +import type { + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type {} from '@deepseek-ai/dsh-tools/types' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' +import type { AssistantChatData, FinalAssistantChatData } from '../contract/chat-nodes.ts' +import { + decodeTurnProcess, encodeTurnProcess, isSubagentDelegationTool, + type TurnProcessSignature, type TurnProcessSpec, +} from '../contract/turn-process.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import { toAssistantBlocks } from './event-projection.ts' + +declare module '../contract/chat-nodes.ts' { + interface ChatNodeDataMap { + /** Turn-level disclosure controlling process rows before the finalized answer. */ + 'turn-process': import('../contract/chat-nodes.ts').TurnProcessChatData + } +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationTurnDataMap { + /** Encoded process range and finalized answer boundary for this Turn. */ + 'turn-process': TurnProcessSignature + } +} + +interface TurnProcessState { + readonly turn: number + readonly assistantStartByStep: ReadonlyMap + readonly messageCountByStep: ReadonlyMap + readonly otherStartSeq?: number + readonly toolCallCount: number + readonly subagentCount: number +} + +type ConversationEvent = Parameters[0] + +function isChunkRunEvent(event: ConversationEvent): event is ChunkRowEvent { + return event.type === 'chunkrow/text-chunks' + || event.type === 'chunkrow/reasoning-chunks' + || event.type === 'chunkrow/tool-call-chunks' +} + +function eventTurn(event: ConversationEvent): number | undefined { + const data = event.data as unknown as { turn?: unknown } + return typeof data.turn === 'number' ? data.turn : undefined +} + +function visibleAssistantEvent(event: ConversationEvent): boolean { + if (event.type === 'assistant/chunk') { + const chunk = event.data.chunk + if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') return chunk.text.trim() !== '' + if (chunk.type === 'block-start') { + return chunk.blockType !== 'text' + && chunk.blockType !== 'reasoning' + && chunk.blockType !== 'tool-call' + } + if (chunk.type !== 'block-end') return false + const block = chunk.block + if (block.type === 'tool-call') return false + if (block.type === 'text' || block.type === 'reasoning') return block.text.trim() !== '' + return true + } + return event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && toAssistantBlocks(event.data.message.content).some((block) => { + if (block.kind === 'tool-call') return false + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +type ProcessEvidence = + | { readonly kind: 'assistant'; readonly seq: number; readonly step: number } + | { readonly kind: 'other'; readonly seq: number } + +function processEvidence(event: ConversationEvent): ProcessEvidence | undefined { + if (isChunkRunEvent(event)) { + if (event.type === 'chunkrow/tool-call-chunks') return undefined + const firstVisible = event.data.texts.findIndex(text => text.trim() !== '') + return firstVisible < 0 + ? undefined + : { kind: 'assistant', seq: event.seq + firstVisible, step: event.data.step } + } + if (visibleAssistantEvent(event)) { + if (event.type !== 'assistant/chunk' && event.type !== 'assistant/message') return undefined + return { kind: 'assistant', seq: event.seq, step: event.data.step } + } + if (event.type === 'tool/call' + || (event.type === 'tool/result' && isAppendSurfaceEvent(event)) + || event.type === 'llm/retry') return { kind: 'other', seq: event.seq } + return undefined +} + +function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { + const location: ConversationLocation | undefined = context.start?.location ?? context.matches.at(-1)?.location + return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined +} + +function fallbackState(context: ConversationNodeContext): TurnProcessState | undefined { + const turn = context.matches.map(match => eventTurn(match.event)).find(candidate => candidate !== undefined) + if (turn === undefined) return undefined + let state: TurnProcessState = { + turn, + assistantStartByStep: new Map(), + messageCountByStep: new Map(), + toolCallCount: 0, + subagentCount: 0, + } + for (const match of context.matches) state = updateProcessState(state, match.event) + return state +} + +function isFinalAssistant( + data: Readonly | undefined, +): data is Readonly { + return data?.finalNode !== undefined +} + +function latestAnswer(turn: TurnLocation): Readonly | null { + const latestStep = turn.steps.at(-1) + const data: Readonly | undefined = latestStep?.data.get('assistant-step') + if (!isFinalAssistant(data) || !hasAssistantReplyContent(data.blocks)) return null + return data.blocks.some(block => block.kind === 'tool-call') ? null : data +} + +function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null { + const controlAnchorSeq = Math.min( + state.otherStartSeq ?? Number.POSITIVE_INFINITY, + ...state.assistantStartByStep.values(), + ) + if (!Number.isFinite(controlAnchorSeq)) return null + const answer = latestAnswer(turn) + const counts = { + messageCount: answer === null + ? [...state.messageCountByStep.values()].reduce((total, count) => total + count, 0) + : [...state.messageCountByStep] + .filter(([step]) => step < answer.step) + .reduce((total, [, count]) => total + count, 0), + toolCallCount: state.toolCallCount, + subagentCount: state.subagentCount, + } + if (answer === null) { + return { + turn: turn.turn, + controlAnchorSeq, + processStartSeq: controlAnchorSeq, + answerAnchorSeq: null, + answerStep: null, + inlineReasoning: false, + ...counts, + } + } + const inlineReasoning = answer.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') + const earlierAssistantSeq = Math.min( + ...[...state.assistantStartByStep] + .filter(([step]) => step < answer.step) + .map(([, seq]) => seq), + ) + const externalProcessSeq = Math.min( + state.otherStartSeq ?? Number.POSITIVE_INFINITY, + earlierAssistantSeq, + ) + return { + turn: turn.turn, + controlAnchorSeq, + processStartSeq: turn.start?.seq + ?? (Number.isFinite(externalProcessSeq) ? externalProcessSeq : answer.finalNode.seq), + answerAnchorSeq: answer.finalNode.seq, + answerStep: answer.step, + inlineReasoning, + ...counts, + } +} + +function updateProcessState(state: TurnProcessState, event: ConversationEvent): TurnProcessState { + let current = state + if (event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) { + const messageCountByStep = new Map(current.messageCountByStep) + messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1) + current = { ...current, messageCountByStep } + } + if (event.type === 'tool/call') { + const subagent = isSubagentDelegationTool(event.data.name) + current = { + ...current, + toolCallCount: current.toolCallCount + (subagent ? 0 : 1), + subagentCount: current.subagentCount + (subagent ? 1 : 0), + } + } + const evidence = processEvidence(event) + if (evidence === undefined) return current + if (evidence.kind === 'other') { + return current.otherStartSeq === undefined ? { ...current, otherStartSeq: evidence.seq } : current + } + if (current.assistantStartByStep.has(evidence.step)) return current + const assistantStartByStep = new Map(current.assistantStartByStep) + assistantStartByStep.set(evidence.step, evidence.seq) + return { ...current, assistantStartByStep } +} + +/** Turn-scoped process range and answer-boundary Definition. */ +export const turnProcessDefinition: ConversationNodeDefinition = { + kind: 'turn-process', + target: 'chat', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + const turn = eventTurn(event) + if (turn === undefined) return null + if (event.type === 'assistant/chunk' + || event.type === 'assistant/message' + || isChunkRunEvent(event) + || event.type === 'tool/call' + || event.type === 'tool/result' + || event.type === 'llm/retry' + || event.type === 'step/start' + || event.type === 'step/end' + || event.type === 'turn/end') { + return { id: String(turn), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-process start requires turn/start') + return { + turn: match.event.data.turn, + assistantStartByStep: new Map(), + messageCountByStep: new Map(), + toolCallCount: 0, + subagentCount: 0, + } + }, + update: (context, match) => updateProcessState(context.state, match.event), + publication: (match) => { + if (isChunkRunEvent(match.event)) return 'animation-frame' + if (match.event.type === 'assistant/chunk') { + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + } + return 'immediate' + }, + buildLocationData: (context, scope) => { + if (scope !== 'turn') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const turn = turnLocation(context) + if (turn === undefined) return null + const spec = processSpec(state, turn) + return spec === null ? null : { + kind: 'turn', + turn: turn.turn, + key: 'turn-process', + value: encodeTurnProcess(spec), + } + }, + buildViewNode: (context) => { + const turn = turnLocation(context) + const signature = turn?.data.get('turn-process') + if (turn === undefined || signature === undefined) return null + const data = decodeTurnProcess(signature) + return chatNode( + context, + 'turn-process', + data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl, + data, + ) + }, +} + +/** + * Register the Turn-scoped process disclosure projection. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnProcess(ctx: Context): void { + ctx.uiConversation.events.register(turnProcessDefinition) +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 62893382b6..f45b686af5 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -10,6 +10,7 @@ export type {} from './conversation-nodes/retry.ts' export type {} from './conversation-nodes/tool.ts' export type {} from './conversation-nodes/turn-error.ts' export type {} from './conversation-nodes/turn-max-tokens.ts' +export type {} from './conversation-nodes/turn-process.ts' export type {} from './conversation-nodes/turn-tail.ts' export type { @@ -23,15 +24,21 @@ export type { export type { AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind, FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, - TurnTailChatData, + TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' -export type { ToolCallId, ChatStoreState, SelectionTarget } from './contract/store.ts' +export type { ChatStoreState, SelectionTarget, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' +export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' +export type { TranscriptViewMode } from '../chat-settings.ts' export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, - DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + DetailsToolOwnerProps, MessageImagesProps, + TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, } from './contract/slots.ts' +export type { + TurnProcessGeneration, TurnProcessSignature, TurnProcessSpec, +} from './contract/turn-process.ts' export type { ChatKey } from './locale.ts' export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' export type { diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index a693a7760f..3c211c9d05 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -32,6 +32,10 @@ export const zh = { 'chat.turnNavigation.label': '轮次导航', 'chat.turnNavigation.jump': '跳转到第 {turn} 轮', 'chat.turnNavigation.turn': '第 {turn} 轮', + 'settings.transcript.title': '对话显示', + 'settings.transcript.description': '控制已完成轮次的过程内容', + 'settings.transcript.normal': 'Normal', + 'settings.transcript.compact': 'Compact', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', @@ -61,6 +65,14 @@ export const zh = { 'message.think': '思考', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', + 'message.turnProcess.toolCalls.one': '{count} 次工具调用', + 'message.turnProcess.toolCalls.other': '{count} 次工具调用', + 'message.turnProcess.messages.one': '{count} 条消息', + 'message.turnProcess.messages.other': '{count} 条消息', + 'message.turnProcess.subagents.one': '{count} 个 subagent', + 'message.turnProcess.subagents.other': '{count} 个 subagent', + 'message.turnProcess.thoughtForAWhile': '已思考', + 'message.turnProcess.separator': ' · ', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', @@ -91,9 +103,9 @@ export const zh = { 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', - 'command.failed': '命令失败', + 'command.failed': '指令失败', 'command.done': '已完成', - 'command.title': '命令', + 'command.title': '指令', 'row.running': '运行中', 'row.failed': '失败', 'json.truncated': '… 已截断,共 {total} 字符', @@ -133,6 +145,10 @@ export const en = { 'chat.turnNavigation.label': 'Turn navigation', 'chat.turnNavigation.jump': 'Jump to turn {turn}', 'chat.turnNavigation.turn': 'Turn {turn}', + 'settings.transcript.title': 'Conversation display', + 'settings.transcript.description': 'Controls process content in completed turns', + 'settings.transcript.normal': 'Normal', + 'settings.transcript.compact': 'Compact', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'fileOpen.folderTitle': 'Couldn’t open folder', @@ -162,6 +178,14 @@ export const en = { 'message.think': 'Think', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', + 'message.turnProcess.toolCalls.one': '{count} tool call', + 'message.turnProcess.toolCalls.other': '{count} tool calls', + 'message.turnProcess.messages.one': '{count} message', + 'message.turnProcess.messages.other': '{count} messages', + 'message.turnProcess.subagents.one': '{count} subagent', + 'message.turnProcess.subagents.other': '{count} subagents', + 'message.turnProcess.thoughtForAWhile': 'Thought for a while', + 'message.turnProcess.separator': ' · ', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css new file mode 100644 index 0000000000..496e01add3 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css @@ -0,0 +1,56 @@ +/* Completed-Turn transcript preference row: label plus selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx new file mode 100644 index 0000000000..f2ab902852 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -0,0 +1,79 @@ +/** General Settings row for completed-Turn transcript presentation. */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TranscriptViewMode } from '../../chat-settings.ts' +import type { ChatKey } from '../locale.ts' +import css from './TranscriptViewRow.module.css' + +/** Registration-side transcript preference face. */ +export interface TranscriptViewRowInjected { + hooks: { + /** Persisted transcript preference bound as useTranscriptView. */ + transcriptView: SnapshotStore + } + /** Change the completed-Turn transcript presentation. */ + setTranscriptView: (mode: TranscriptViewMode) => void +} + +/** Full Settings-row props. */ +export type TranscriptViewRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'chat'> + & InjectFace + +const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [ + { id: 'normal', label: 'settings.transcript.normal' }, + { id: 'compact', label: 'settings.transcript.compact' }, +] + +/** + * Render the completed-Turn transcript mode selector. + * @param props - composed Settings slot props. + * @returns the preference row. + */ +export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) { + const mode = useTranscriptView(value => value) + const [open, setOpen] = useState(false) + const selectedLabel = mode === 'normal' + ? 'settings.transcript.normal' + : 'settings.transcript.compact' + const closeMenu = () => { setOpen(false) } + const selectMode = (id: string) => { + closeMenu() + setTranscriptView(id as TranscriptViewMode) + } + const selector = ( + + ) + + return ( +
+
+
{t('settings.transcript.title')}
+
{t('settings.transcript.description')}
+
+ ({ id: option.id, label: t(option.label) }))} + selectedId={mode} + onSelect={selectMode} + align="end" + portal + anchor={selector} + /> +
+ ) +} diff --git a/packages/client/ui-chat/src/client/stores.ts b/packages/client/ui-chat/src/client/stores.ts index 7ff6b80ad7..be2c411f7b 100644 --- a/packages/client/ui-chat/src/client/stores.ts +++ b/packages/client/ui-chat/src/client/stores.ts @@ -1,9 +1,29 @@ /** Per-Session Chat selection store shared by the transcript and details panel. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' -import type { ChatStoreState, SelectionTarget } from './contract/store.ts' +import type { TurnProcessGeneration } from './contract/turn-process.ts' +import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts' type ChatActions = { select: (draft: ChatStoreState, target: SelectionTarget | null) => void + setTurnProcessOpen: ( + draft: ChatStoreState, + turn: number, + generation: TurnProcessGeneration, + open: boolean, + ) => void +} + +/** + * Resolve any stored generation for one Turn. + * @param state - Chat store snapshot. + * @param turn - owning Turn. + * @returns the Turn's stored entry, when present. + */ +export function storedTurnProcessEntry( + state: Readonly, + turn: number, +): Readonly | undefined { + return state.turnProcesses.find(entry => entry.turn === turn) } /** @@ -12,9 +32,19 @@ type ChatActions = { */ export function createChatStore(): EngineStoreHandle { return defineStore({ - init: (): ChatStoreState => ({ selection: null }), + init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }), actions: { select: (draft, target: SelectionTarget | null) => { draft.selection = target }, + setTurnProcessOpen: (draft, turn, generation, open) => { + const index = draft.turnProcesses.findIndex(entry => entry.turn === turn) + if (!open) { + if (index >= 0) draft.turnProcesses.splice(index, 1) + return + } + const next = { turn, generation } satisfies TurnProcessViewEntry + if (index < 0) draft.turnProcesses.push(next) + else draft.turnProcesses[index] = next + }, }, }) } diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts new file mode 100644 index 0000000000..5534a0139c --- /dev/null +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -0,0 +1,39 @@ +/** Host-backed completed-Turn transcript presentation policy. */ + +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +import { + DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + type ChatSettings, type TranscriptViewMode, +} from '../chat-settings.ts' + +/** Live transcript preference consumed by Chat and its Settings row. */ +export class TranscriptViewPolicy { + /** Reactive current mode; defaults to Compact before Host settings arrive. */ + readonly mode: SnapshotStore = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE) + + /** + * @param host - durable Chat settings scope. + */ + constructor(private readonly host: SettingsScope) { + host.subscribe(() => { this.adopt() }) + this.adopt() + } + + /** + * Publish and persist one explicit user choice. + * @param mode - Normal or Compact transcript presentation. + */ + setMode(mode: TranscriptViewMode): void { + if (this.mode.getSnapshot() === mode) return + this.mode.set(mode) + void this.host.set(TRANSCRIPT_VIEW_FIELD, mode) + } + + /** Adopt the latest accepted Host section without writing it back. */ + private adopt(): void { + const section = this.host.getSnapshot().value + if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return + this.mode.set(section.transcriptView) + } +} diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 4c47106067..0faa47d878 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -1,4 +1,20 @@ -/** Host loader entry for the browser-only Chat UI target. */ +/** Host registration for browser Chat preferences. */ -/** Provides no Host-side behavior. */ -export function apply(): void {} +import type { Context } from '@deepseek-ai/cordis' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { CHAT_SETTINGS_NAMESPACE, ChatSettingsSchema } from './chat-settings.ts' + +export { + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD, + TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode, +} from './chat-settings.ts' + +/** Register the durable Chat settings section when a provider exists. */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(CHAT_SETTINGS_NAMESPACE), + ChatSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 0fd861a14a..9ceef64dd7 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -5,9 +5,10 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { - SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, + SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -48,10 +49,12 @@ async function bench() { runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.ctx.provide('layout', layout as never) - const openPath = vi.fn<(path: string) => Promise>(async () => {}) + const openWorkspacePath = vi.fn( + () => Promise.resolve({ ok: true, value: { opened: true } }), + ) + new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => ROOT), - openPath, } as never) const session = sessionFakeFor() await runtime.sessions.add({ @@ -79,7 +82,7 @@ async function bench() { ) => ChatViewInjected)(id, instance.actions) return { instance, injected } } - return { runtime, layout, openPath, session, chatViewApi } + return { runtime, layout, openWorkspacePath, session, chatViewApi } } describe('Chat inject API', () => { @@ -120,10 +123,13 @@ describe('Chat inject API', () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) await injected.openFile('src/a.ts') - expect(b.openPath).toHaveBeenCalledWith('/proj/src/a.ts') + expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: '/proj/src/a.ts' }) - b.openPath.mockRejectedValueOnce(new Error('xdg-open is not available')) - await expect(injected.openFile('src/b.ts')).rejects.toThrow('xdg-open is not available') + b.openWorkspacePath.mockResolvedValueOnce({ + ok: false, + error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + }) + await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available') await b.runtime.dispose() }) @@ -169,8 +175,10 @@ describe('Chat inject API', () => { injected.chatScroll.save(null) expect(injected.chatScroll.read()).toBeNull() - await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String)) + const loaded = await injected.loadImage(ATTACHMENT) + expect(loaded).toEqual(expect.any(String)) expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx index 5287b68272..ebe9359b68 100644 --- a/packages/client/ui-chat/tests/approval-command.client.spec.tsx +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -61,9 +61,9 @@ describe('ApprovalCommand', () => { }) describe('ui-chat package entries', () => { - it('keeps the Host half inert and registers the invariant companion', async () => { - expect(() => { nodeApply() }).not.toThrow() + it('keeps the Host half optional and registers the invariant companion', async () => { const ctx = new Context() + expect(() => { nodeApply(ctx) }).not.toThrow() await ctx.plugin(InvariantRegistry, { enabled: true }) await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined() diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index eaf64ab256..d9c1538190 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' import { - chatSnapshot, SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, + chatSnapshot, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -15,8 +15,9 @@ import { apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { - ChatNodeTurnDataInjected, ChatSnapshot, UseChat, + ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChat, } from '@deepseek-ai/dsh-client-ui-chat/client' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationTurnDataMap { @@ -30,12 +31,19 @@ const SID = 'session-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + const chatSettings = stubSettingsScope() + runtime.ctx.provide('settingsScope', { + bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE + ? chatSettings.scope + : stubSettingsScope().scope, + } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), - openPath: vi.fn(async () => {}), } as never) + new TestRemote(runtime.ctx, { + session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) }, + }) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) @@ -53,7 +61,7 @@ async function bench() { const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) const sourceDescriptor = provide.mock.calls[0]?.[0] if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source') - return { runtime, conversation, chat, sourceDescriptor } + return { runtime, conversation, chat, chatSettings, sourceDescriptor } } function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { @@ -70,10 +78,30 @@ describe('Chat apply wiring', () => { .toMatchObject({ kind: 'keyed', scope: 'session' }) expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) .toEqual(['stats']) + expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) + .toEqual(['transcript-view', 'composer-enter']) expect(b.runtime.slots.entries('details')).toHaveLength(1) await b.runtime.dispose() }) + it('mirrors the Host transcript preference into its Settings row', async () => { + const b = await bench() + const row = b.runtime.slots.entries('settings.general.item') + .find(entry => entry.options.id === 'transcript-view')! + const face = (row.inject as unknown as () => TranscriptViewRowInjected)() + + expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') + face.setTranscriptView('normal') + expect(face.hooks.transcriptView.getSnapshot()).toBe('normal') + expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') + + b.chatSettings.publish({ + status: 'ready', value: { transcriptView: 'compact' }, revision: 1, writable: true, + }) + expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') + await b.runtime.dispose() + }) + it('shares one Chat store while keeping it distinct from Conversation state', async () => { const b = await bench() const conversationStore = storeOf(b.runtime, 'conversation.session') diff --git a/packages/client/ui-chat/tests/chat-settings.client.spec.ts b/packages/client/ui-chat/tests/chat-settings.client.spec.ts new file mode 100644 index 0000000000..cd23c41763 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-settings.client.spec.ts @@ -0,0 +1,37 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, apply, +} from '../src/index.ts' + +class MemorySettings extends SettingsProvider { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-chat Host settings', () => { + it('registers, validates, and disposes the transcript-view namespace', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(CHAT_SETTINGS_NAMESPACE) + + expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE }) + await ctx.settings.update(ns, { transcriptView: 'normal' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal' }) + await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow() + + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) + + it('loads without a settings provider', async () => { + const ctx = new Context() + await expect(ctx.plugin({ apply }).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index dce67d27ec..39920f0201 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,7 +1,7 @@ import type { - AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, - ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice, - PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem, + AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, + ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData, + LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, @@ -10,6 +10,12 @@ import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' import { sameTurnNavigationItem, turnNavigationItem, } from '../src/client/conversation-nodes/turn-navigation.ts' +import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' +import { + encodeTurnProcess, isSubagentDelegationTool, TURN_PROCESS_INDEPENDENT_KINDS, + type TurnProcessSpec, +} from '../src/client/contract/turn-process.ts' const EMPTY: readonly never[] = [] @@ -17,17 +23,45 @@ function sameValues(left: readonly T[], right: readonly T[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) } +function sameFixtureLocation( + left: ChatConversationViewNode['location'], + right: ChatConversationViewNode['location'], +): boolean { + if (left.kind !== right.kind) return false + if (left.kind === 'session' || left.kind === 'unresolved') return true + if (right.kind === 'session' || right.kind === 'unresolved') return false + if (left.turn.turn !== right.turn.turn + || left.turn.status !== right.turn.status + || left.turn.start !== right.turn.start + || left.turn.end !== right.turn.end + || left.turn.data !== right.turn.data) return false + if (left.kind === 'turn' || right.kind === 'turn') return left.kind === right.kind + return left.step.step === right.step.step + && left.step.status === right.step.status + && left.step.start === right.step.start + && left.step.end === right.step.end + && left.step.data === right.step.data +} + function nodeSource(node: ChatConversationViewNode): unknown { if (node.kind === 'assistant-step') { - const data = node.data as ReturnType + const data = node.data as AssistantChatData return data.finalNode ?? data.blocks } if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq + if (node.kind === 'turn-process') { + const data = node.data as TurnProcessSpec + return encodeTurnProcess(data) + } return node.data } +function toolCallName(call: ToolCallBlock): string | null { + return 'name' in call ? call.name : call.call?.name ?? null +} + class FixtureNodeStore implements ChatNodeStore { private byKey = new Map() private list: readonly ChatConversationViewNode[] = EMPTY @@ -47,6 +81,7 @@ class FixtureNodeStore implements ChatNodeStore { const node = previous !== undefined && previous.kind === candidate.kind && previous.anchorSeq === candidate.anchorSeq + && sameFixtureLocation(previous.location, candidate.location) && previous.visibility === candidate.visibility && nodeSource(previous) === nodeSource(candidate) ? previous @@ -97,7 +132,7 @@ class FixtureTurnDataStore implements ConversationLocationDataStore, + inferredTurn?: number, ): ChatConversationViewNode { - const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined + const ownTurn = 'turn' in node && typeof node.turn === 'number' ? node.turn : inferredTurn + const turn = ownTurn === undefined ? undefined : turns.get(ownTurn) const base = { key: `fixture:${node.kind}:${node.seq}`, id: String(node.seq), @@ -161,7 +198,8 @@ export function chatSnapshotFixture(input: { for (const turn of [...turnNumbers].sort((left, right) => left - right)) { const timing = legacy.turnTimings.get(turn) const endSeq = legacy.turnEnds.get(turn) - const data = new FixtureTurnDataStore() + const previousData = previous?.timeline.turns.get(turn)?.data + const data = previousData instanceof FixtureTurnDataStore ? previousData : new FixtureTurnDataStore() turnData.set(turn, data) turns.set(turn, { turn, @@ -177,7 +215,7 @@ export function chatSnapshotFixture(input: { }) } const linkedCompactions = new Set() - const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => { + const nodes = legacy.nodes.flatMap((node, index): ChatConversationViewNode[] => { if (node.kind === 'command' && node.name === 'compact') { const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined const candidates = sourceSeq === undefined @@ -198,7 +236,12 @@ export function chatSnapshotFixture(input: { } } if (node.kind === 'compaction' && linkedCompactions.has(node)) return [] - return [settledNode(node, turns)] + const inferredTurn = node.kind === 'tool-result' + ? legacy.nodes.slice(0, index).findLast( + (candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant', + )?.turn + : undefined + return [settledNode(node, turns, inferredTurn)] }) if (legacy.partial !== null) { const turn = turns.get(legacy.partial.turn) @@ -232,6 +275,75 @@ export function chatSnapshotFixture(input: { data: { root: call }, }) } + for (const [turnNumber, dataStore] of turnData) { + const inTurn = nodes.filter((candidate) => { + const location = candidate.location + return (location.kind === 'turn' || location.kind === 'step') && location.turn.turn === turnNumber + }) + const assistants = inTurn + .filter(candidate => candidate.kind === 'assistant-step') + .map(candidate => candidate.data as AssistantChatData) + const toolCalls = inTurn + .filter(candidate => candidate.kind === 'tool-call') + .map(candidate => (candidate.data as { readonly root: ToolCallBlock }).root) + const latestStep = Math.max( + 0, + ...assistants.map(candidate => candidate.step), + ...inTurn.flatMap((candidate) => { + if (candidate.kind !== 'tool-call') return [] + const root = (candidate.data as { root: ToolCallBlock }).root as ToolCallBlock & { step?: unknown } + const step: unknown = root.step + return typeof step === 'number' ? [step] : [] + }), + ) + const answer = assistants.findLast((candidate): candidate is FinalAssistantChatData => + candidate.step === latestStep + && candidate.finalNode !== undefined + && hasAssistantReplyContent(candidate.blocks) + && !candidate.blocks.some(block => block.kind === 'tool-call')) + const controlAnchor = inTurn.find(candidate => candidate.kind === 'assistant-step' + || candidate.kind === 'tool-call' + || candidate.kind === 'model-retry') + if (controlAnchor === undefined) continue + const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind)) + ?? controlAnchor + const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true + const spec: TurnProcessSpec = { + turn: turnNumber, + controlAnchorSeq: controlAnchor.anchorSeq, + processStartSeq: processStart.anchorSeq, + answerAnchorSeq: answer?.finalNode.seq ?? null, + answerStep: answer?.step ?? null, + inlineReasoning: answer !== undefined && inlineReasoning, + messageCount: answer === undefined + ? assistants.filter(candidate => hasAssistantReplyContent(candidate.blocks)).length + : assistants.filter(candidate => candidate.step < answer.step + && hasAssistantReplyContent(candidate.blocks)).length, + toolCallCount: toolCalls.filter((call) => { + const name = toolCallName(call) + return name === null || !isSubagentDelegationTool(name) + }).length, + subagentCount: toolCalls.filter((call) => { + const name = toolCallName(call) + return name !== null && isSubagentDelegationTool(name) + }).length, + } + dataStore.set('turn-process', encodeTurnProcess(spec)) + const turn = turns.get(turnNumber) + if (turn !== undefined) { + nodes.push({ + key: `fixture:turn-process:${String(turnNumber)}`, + id: String(turnNumber), + target: 'chat', + kind: 'turn-process', + anchorSeq: spec.controlAnchorSeq - 0.1, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: spec, + }) + } + } + nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) for (const [turnNumber, endSeq] of legacy.turnEnds) { const turn = turns.get(turnNumber) const dataStore = turnData.get(turnNumber) @@ -271,10 +383,12 @@ export function chatSnapshotFixture(input: { data: tailData, }) } + nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) + const ordered = orderedVisibleChatNodes(nodes) const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore() - store.replace(nodes) + store.replace(ordered) const byKey = new Map(store.values().map(node => [node.key, node])) - const nextOrder = nodes.map(node => node.key) + const nextOrder = ordered.map(node => node.key) const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder const byTurn = new Map() for (const turn of turns.keys()) { diff --git a/packages/client/ui-chat/tests/chat-store.client.spec.ts b/packages/client/ui-chat/tests/chat-store.client.spec.ts index efdda72dae..e0f09bfda1 100644 --- a/packages/client/ui-chat/tests/chat-store.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-store.client.spec.ts @@ -4,7 +4,7 @@ import { createChatStore } from '../src/client/stores.ts' describe('createChatStore', () => { it('starts without a selected Chat target', () => { const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null }) + expect(store.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] }) }) it('selects and clears one Chat details target', () => { @@ -23,4 +23,27 @@ describe('createChatStore', () => { first.actions.select({ turnSeq: 1 }) expect(second.store.getSnapshot().selection).toBeNull() }) + + it('stores only manually expanded Turn-process generations', () => { + const store = createChatStore().create() + store.actions.setTurnProcessOpen(2, '2|3', true) + expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|3' }]) + + store.actions.setTurnProcessOpen(2, '2|4', true) + expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|4' }]) + + store.actions.setTurnProcessOpen(2, '2|4', false) + expect(store.store.getSnapshot().turnProcesses).toEqual([]) + }) + + it('closes only the requested Turn-process entry', () => { + const store = createChatStore().create() + store.actions.setTurnProcessOpen(2, '2|3', true) + store.actions.setTurnProcessOpen(3, '3|4', true) + + store.actions.setTurnProcessOpen(2, '2|3', false) + store.actions.setTurnProcessOpen(9, '9|10', false) + + expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 3, generation: '3|4' }]) + }) }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1f200a2029..85a5d1f0c9 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -5,10 +5,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { useEffect } from 'react' import type { AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot, - ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, - ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, - UseChatNodeTurnData, UserMessageNode, + ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, + LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, SteeringMessageNode, + ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData, + TranscriptViewMode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState, SessionSnapshot, @@ -23,6 +23,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' +import { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx' import { zh } from '../src/client/locale.ts' import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' @@ -31,7 +32,11 @@ import { TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' +import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx' +import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { encodeTurnProcess } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' afterEach(() => { @@ -51,6 +56,7 @@ function sessionSnapshot(overrides: Partial = {}): SessionSnaps return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', @@ -87,6 +93,7 @@ function makeSessionSource(init: Partial = {}) { } type ChatSlice = Partial +type HarnessUpdate = ChatSlice & Partial & { readonly chat?: ChatSnapshot } /** Scripted Chat target source, independent from Session lifecycle state. */ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { @@ -97,6 +104,10 @@ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap) for (const fn of [...subs]) fn() }, + replace: (next: ChatSnapshot) => { + snap = next + for (const fn of [...subs]) fn() + }, source: { getSnapshot: () => snap, subscribe: (fn: () => void) => { @@ -120,8 +131,20 @@ const userInTurn = (seq: number, text: string, turn: number): ConversationNode = // accepts the extra coordinate so component tests can build the same view. turn, } as unknown as ConversationNode) -const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ - kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], +const assistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text }], +}) +const reasoningAssistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'reasoning', text }], +}) +const context = (seq: number, text: string, turn?: number): ContextMessageNode & { turn?: number } => ({ + kind: 'context', seq, time: seq * 1_000, content: [{ type: 'text', text }], source: null, + provenance: { role: 'inject', label: null }, form: null, + ...(turn === undefined ? {} : { turn }), +}) +const steering = (seq: number, text: string, turn: number): SteeringMessageNode & { turn: number } => ({ + kind: 'steering', messageId: `steering-${String(seq)}` as SteeringMessageNode['messageId'], + seq, time: seq * 1_000, turn, content: [{ type: 'text', text }], source: null, }) const retry = (seq: number): ModelRetryNode => ({ kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'], @@ -177,12 +200,23 @@ function emptyWorkspaces() { } function makeHarness( - chatSlice: ChatSlice = {}, - sessionInit: Partial = {}, + init: HarnessUpdate = {}, + sessionOverrides: Partial = {}, chatSnapshot?: ChatSnapshot, ) { - const session = makeSessionSource(sessionInit) - const chatSource = makeChatSource(chatSlice, chatSnapshot) + const { + chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, + ...sessionInit + } = init + const chatSlice: ChatSlice = { + ...(nodes === undefined ? {} : { nodes }), + ...(partial === undefined ? {} : { partial }), + ...(runningCalls === undefined ? {} : { runningCalls }), + ...(turnTimings === undefined ? {} : { turnTimings }), + ...(turnEnds === undefined ? {} : { turnEnds }), + } + const session = makeSessionSource({ ...sessionInit, ...sessionOverrides }) + const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot) const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() @@ -196,6 +230,7 @@ function makeHarness( const forkAt = vi.fn() // Rows and the harness must observe the same chat-store instance. const chat = createChatStore().create() + const transcriptView = createSnapshotStore('compact') const t = makeTranslate(zh, commonZh) const toolOwners: Array<{ callId: string @@ -211,10 +246,12 @@ function makeHarness( React.ComponentProps['renderSlotChain'] const renderTurnTailSlot = (() => null) as unknown as React.ComponentProps['renderSlot'] - const renderSlot = ((key: string, owner: object, opts?: { + let nodeSlotOverride: React.ComponentProps['renderSlot'] | undefined + const renderNodeSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode hookContext?: unknown }) => { + if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never) if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined @@ -253,6 +290,10 @@ function makeHarness( return ()} /> case 'turn-max-tokens': return ()} /> + case 'turn-process': + return ()} /> + case 'system-prompt': + return ()} /> case 'turn-tail': return ( ['renderSlot'] + const renderSlot = renderNodeSlot // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} @@ -315,6 +357,7 @@ function makeHarness( }, useStore: bindSnapshotSelector(chat), actions: chat.actions, + useTranscriptView: bindSnapshotSelector(transcriptView), renderSlot, SessionProvider: SessionProviderStub, viewRequest: null, @@ -330,11 +373,33 @@ function makeHarness( fileMentions: () => undefined, t, } + const set = (next: HarnessUpdate): void => { + const { + chat: explicitChat, nodes, partial, runningCalls, turnTimings, turnEnds, + ...sessionUpdate + } = next + if (explicitChat !== undefined) chatSource.replace(explicitChat) + else if (nodes !== undefined || partial !== undefined || runningCalls !== undefined + || turnTimings !== undefined || turnEnds !== undefined) { + chatSource.set({ + ...(nodes === undefined ? {} : { nodes }), + ...(partial === undefined ? {} : { partial }), + ...(runningCalls === undefined ? {} : { runningCalls }), + ...(turnTimings === undefined ? {} : { turnTimings }), + ...(turnEnds === undefined ? {} : { turnEnds }), + }) + } + session.set(sessionUpdate) + } const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } return { - setSession: session.set, setChat: chatSource.set, ChatView, props, + set, setSession: session.set, setChat: chatSource.set, ChatView, props, openDetails, openFile, loadOlder, openView, chatScroll, forkAt, setSelection, toolOwners, + setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, + setNodeRenderer: (renderer: React.ComponentProps['renderSlot']) => { + nodeSlotOverride = renderer + }, } } @@ -345,6 +410,34 @@ function readerScroll(element: HTMLElement, top: number): void { fireEvent.scroll(element) } +function turnProcessControl(container: HTMLElement): HTMLButtonElement | null { + return container.querySelector('[data-turn-process]') +} + +function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapshot { + const turn = snapshot.timeline.turns.get(1) + if (turn === undefined) throw new Error('fixture lacks Turn 1') + const prompt: ChatNode<'system-prompt'> = { + key: 'fixture:system-prompt:1', + id: '1', + target: 'chat', + kind: 'system-prompt', + anchorSeq: 1, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: { text }, + } + return new ChatSnapshotBuilder().replace({ + nodes: [prompt, ...snapshot.nodes.values()], + timeline: snapshot.timeline, + }) +} + +function renderedFlowKinds(container: HTMLElement): Array { + return [...container.querySelectorAll('[data-chat-flow-kind]')] + .map(row => row.dataset.chatFlowKind) +} + function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) { let scrollHeight = initialHeight let scrollTop = 0 @@ -412,6 +505,34 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { + const snapshot = chatSnapshotFixture({ + nodes: [ + userInTurn(1, 'first prompt', 1), + assistant(2, 'first response', 1), + userInTurn(4, 'second prompt', 2), + assistant(5, 'second response', 2), + ], + turnEnds: new Map([[1, 3], [2, 6]]), + }) + const h = makeHarness({}, {}, snapshot) + // The rail asks for its own accessible name once per render, so counting + // that key counts renders without reaching into the component. + let railRenders = 0 + const translate = h.props.t + const counting = ((key: string, vars?: Record) => { + if (key === 'chat.turnNavigation.label') railRenders += 1 + return (translate as (k: string, v?: Record) => string)(key, vars) + }) as ChatViewSlotProps['t'] + render() + const afterMount = railRenders + expect(afterMount).toBeGreaterThan(0) + + act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) + + expect(railRenders).toBe(afterMount) + }) + it('projects loaded turns into prompt and response navigation previews', () => { const snapshot = chatSnapshotFixture({ nodes: [ @@ -621,6 +742,7 @@ describe('ChatView', () => { kind: row.getAttribute('data-chat-flow-kind'), }))).toEqual([ { key: 'fixture:user:1', kind: 'user' }, + { key: 'fixture:turn-process:1', kind: 'turn-process' }, { key: 'fixture:assistant:2', kind: 'assistant-step' }, { key: 'fixture:tool:a', kind: 'tool-call' }, { key: 'fixture:tool:b', kind: 'tool-call' }, @@ -629,7 +751,7 @@ describe('ChatView', () => { .toEqual(['a', 'b']) expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) .toEqual([ - 'fixture:user:1', 'fixture:assistant:2', + 'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2', 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', ]) }) @@ -728,6 +850,99 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1) }) + it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + pendingSubmissions: [ + { requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull() + + // The durable node arrives while the echo is STILL in the session + // snapshot: the render-time rpcId dedupe keeps exactly one bubble. + act(() => { + h.setChat({ + nodes: [ + assistant(1, 'working'), + { + kind: 'user', seq: 2, time: 2_000, + content: [{ type: 'text', text: '即发即显' }] as never, + source: { kind: 'user', rpcId: 'req-1' }, + }, + ], + }) + }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() + + // The delayed snapshot retirement changes nothing visible. + act(() => { h.setSession({ pendingSubmissions: [] }) }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + }) + + it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + running: true, + pendingSubmissions: [ + { requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('排队中')).toBeTruthy() + act(() => { + h.setSession({ + queue: [{ + id: 'q-occurrence' as never, + messageId: 'q-message' as never, + placement: 'queued' as const, + rpcId: 'req-q' as never, + content: [{ type: 'text' as const, text: '排队中' }], + preview: '排队中', + text: '排队中', + }], + }) + }) + // The queued occurrence renders in the queue dock, not the flow; the + // flow-tail echo yields to it in the same snapshot. + expect(view.queryByText('排队中')).toBeNull() + }) + + it('an image echo renders its previews through the message-image slot', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-img' as never, + time: 7_000, + text: '', + images: [ + { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + { previewUrl: 'blob:echo-b' }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const images = (owner as { images: readonly unknown[] }).images + return
+ }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const gallery = view.getByTestId('echo-images') + expect(gallery.getAttribute('data-count')).toBe('2') + expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }) + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } @@ -805,9 +1020,9 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), - assistant(2, 'mid-turn text'), + assistant(2, 'mid-turn text', 1, 1), toolResult(3, 'a'), - assistant(4, 'final answer'), + assistant(4, 'final answer', 1, 2), user(5, 'next'), assistant(6, 'second turn', 2), ], @@ -821,6 +1036,464 @@ describe('ChatView', () => { expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null]) }) + it('folds Think and Tool rows before the final answer without unmounting them', () => { + const first = { + ...assistant(2, 'earlier reply', 1, 1), + blocks: [ + { kind: 'reasoning' as const, text: 'inspect the repository' }, + { kind: 'text' as const, text: 'earlier reply' }, + ], + } + const second = assistant(5, 'final answer', 1, 2) + const h = makeHarness({ + nodes: [ + user(1, 'question'), + first, + toolResult(3, 'a'), + toolResult(4, 'b', 'subagent'), + second, + ], + turnTimings: new Map([[1, { startTime: 1_000, endTime: 5_000 }]]), + turnEnds: new Map([[1, 6]]), + }) + const view = render() + const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1') + expect(toggle.getAttribute('data-turn-process-messages')).toBe('1') + expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1') + const members = [...view.container.querySelectorAll('[data-turn-process-member]')] + expect(members).toHaveLength(3) + expect(members.map(member => member.getAttribute('hidden'))) + .toEqual(['until-found', 'until-found', 'until-found']) + expect(members[0]?.textContent).toContain('inspect the repository') + expect(members[1]?.textContent).toContain('bash:a') + expect(members[2]?.textContent).toContain('subagent:b') + expect(view.getByText('final answer')).toBeTruthy() + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + + fireEvent.click(toggle) + expect(members.map(member => member.getAttribute('hidden'))) + .toEqual(['until-found', 'until-found', 'until-found']) + fireEvent(members[1]!, new Event('beforematch')) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + + act(() => { h.set({ nodes: [user(1, 'question'), first] }) }) + expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false') + expect(members[0]?.getAttribute('hidden')).toBeNull() + act(() => { h.set({ + nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second], + }) }) + const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' }) + expect(renewedToggle.getAttribute('aria-expanded')).toBe('true') + expect(members[0]?.getAttribute('hidden')).toBeNull() + }) + + it('folds injected Context in place with the rest of the Turn process', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy changed', 1), + reasoningAssistant(3, 'inspect the repository', 1, 1), + toolResult(4, 'a'), + assistant(5, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 6]]), + }) + const view = render() + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + const members = [...view.container.querySelectorAll('[data-turn-process-member]')] + + expect(members).toHaveLength(3) + expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call']) + expect(contextRow).not.toBeNull() + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + fireEvent(contextRow!, new Event('beforematch')) + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + }) + + it('keeps the first System prompt above User and outside Process through completion and expansion', () => { + const initial = withSystemPrompt(chatSnapshotFixture({ + nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)], + })) + const running = withSystemPrompt(chatSnapshotFixture({ + nodes: [ + userInTurn(2, 'question', 1), + context(3, 'runtime policy', 1), + reasoningAssistant(4, 'inspect', 1, 1), + ], + })) + const completed = withSystemPrompt(chatSnapshotFixture({ + nodes: [ + userInTurn(2, 'question', 1), + context(3, 'runtime policy', 1), + reasoningAssistant(4, 'inspect', 1, 1), + assistant(6, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 7]]), + })) + const h = makeHarness({ chat: initial }, { running: true }) + const view = render() + const promptRow = view.container.querySelector('[data-chat-flow-kind="system-prompt"]')! + + expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context']) + expect(promptRow.getAttribute('hidden')).toBeNull() + expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) + + act(() => { h.set({ chat: running, running: true }) }) + expect(renderedFlowKinds(view.container)).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', + ]) + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow) + expect(promptRow.getAttribute('hidden')).toBeNull() + + act(() => { h.set({ chat: completed, running: false }) }) + const toggle = turnProcessControl(view.container)! + const members = [...view.container.querySelectorAll('[data-turn-process-member]')] + expect(renderedFlowKinds(view.container)).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(promptRow.getAttribute('hidden')).toBeNull() + expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) + expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step']) + expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found']) + + fireEvent.click(toggle) + expect(renderedFlowKinds(view.container)).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + expect(promptRow.getAttribute('hidden')).toBeNull() + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null]) + }) + + it('folds Context under the fallback title when every summary count is zero', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)], + turnEnds: new Map([[1, 4]]), + }) + const view = render() + const toggle = view.getByRole('button', { name: '已思考' }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0') + expect(toggle.getAttribute('data-turn-process-messages')).toBe('0') + expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + fireEvent.click(toggle) + expect(contextRow?.getAttribute('hidden')).toBeNull() + }) + + it('keeps ordinary spacing when steering separates the process control from its answer', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + reasoningAssistant(2, 'inspect', 1, 1), + steering(3, 'also mention safety', 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + const answer = view.container.querySelector('[data-chat-flow-kind="assistant-step"]:not([hidden])') + + expect(view.getByText('also mention safety')).toBeTruthy() + expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) + }) + + it('keeps ordinary spacing when steering precedes the first process evidence', () => { + const h = makeHarness({ + nodes: [ + steering(1, 'question', 1), + steering(2, 'also mention safety', 1), + reasoningAssistant(3, 'inspect', 1, 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + const answer = view.container.querySelector('[data-chat-flow-kind="assistant-step"]:not([hidden])') + + expect(view.getByText('also mention safety')).toBeTruthy() + expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) + }) + + it('keeps a live Turn expanded and folds it once at turn/end', () => { + const process = assistant(2, 'inspect', 1, 1) + const h = makeHarness({ + nodes: [user(1, 'question'), process], + partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] }, + running: true, + }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + + act(() => { + h.set({ + nodes: [user(1, 'question'), process, assistant(4, 'settled answer', 1, 2)], + partial: null, + running: false, + turnEnds: new Map([[1, 5]]), + }) + }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(processRow.getAttribute('hidden')).toBe('until-found') + }) + + it('switches completed Turns between the persisted Normal and Compact modes', () => { + const process = assistant(2, 'inspect', 1, 1) + const h = makeHarness({ + nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + expect(processRow.getAttribute('hidden')).toBe('until-found') + + act(() => { h.setTranscriptView('normal') }) + expect(turnProcessControl(view.container)).toBeNull() + expect(processRow.getAttribute('hidden')).toBeNull() + + act(() => { h.setTranscriptView('compact') }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + expect(processRow.getAttribute('hidden')).toBe('until-found') + }) + + it('folds final-step reasoning under the fallback title when every summary count is zero', () => { + const final = { + ...assistant(3, 'final answer', 1, 1), + blocks: [ + { kind: 'reasoning' as const, text: 'private analysis' }, + { kind: 'text' as const, text: 'final answer' }, + ], + } + const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) }) + const view = render() + const toggle = view.getByRole('button', { name: '已思考' }) + const reasoning = view.container.querySelector('[data-turn-process-inline]') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(reasoning?.getAttribute('hidden')).toBe('until-found') + expect(view.getByText('final answer')).toBeTruthy() + fireEvent.click(toggle) + expect(view.getByText('private analysis')).toBeTruthy() + }) + + it('folds a completed Turn even while the reader is away from the tail', () => { + const first = assistant(2, 'first answer', 1, 1) + const h = makeHarness({ nodes: [user(1, 'question'), first], running: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) + const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + readerScroll(scroller, 100) + + act(() => { h.set({ + nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + }) }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(firstRow.getAttribute('hidden')).toBe('until-found') + expect(view.getByLabelText('回到底部')).toBeTruthy() + }) + + it('folds when the process controller first appears off-tail', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], + running: true, + }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + readerScroll(scroller, 100) + + act(() => { h.set({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'final answer', 1, 1), + ], + running: false, + turnEnds: new Map([[1, 4]]), + }) }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + expect(view.getByLabelText('回到底部')).toBeTruthy() + }) + + it('keeps a focused process row visible when a live Turn completes', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], + running: true, + }) + const view = render() + const contextToggle = view.getByRole('button', { name: '上下文注入' }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + contextToggle.focus() + expect(document.activeElement).toBe(contextToggle) + + act(() => { h.set({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'final answer', 1, 1), + ], + running: false, + turnEnds: new Map([[1, 4]]), + }) }) + const processToggle = turnProcessControl(view.container)! + expect(processToggle.getAttribute('aria-expanded')).toBe('true') + expect(contextRow?.getAttribute('hidden')).toBeNull() + expect(document.activeElement).toBe(contextToggle) + + fireEvent.click(processToggle) + expect(document.activeElement).toBe(processToggle) + expect(processToggle.getAttribute('aria-expanded')).toBe('false') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + }) + + it('keeps a foldable closed Turn fully visible while history is partial', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'working', 1, 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + hasMore: true, + }) + const view = render() + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + + expect(turnProcessControl(view.container)).toBeNull() + expect(contextRow?.getAttribute('hidden')).toBeNull() + expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false) + + act(() => { h.set({ hasMore: false }) }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + }) + + it('withholds process controls for partial history and folds final-page groups', () => { + const h = makeHarness({ + nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)], + hasMore: true, + }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + + act(() => { + h.set({ + nodes: [ + user(1, 'older question'), + assistant(2, 'older first answer', 1, 1), + assistant(4, 'older final answer', 1, 2), + user(9, 'visible question'), + assistant(10, 'visible answer', 2), + ], + turnEnds: new Map([[1, 5]]), + hasMore: false, + }) + }) + + const toggle = turnProcessControl(view.container)! + const member = view.container.querySelector('[data-turn-process-member]') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(member?.getAttribute('hidden')).toBe('until-found') + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(member?.getAttribute('hidden')).toBeNull() + }) + + it('refreshes process layout without reordering when the final page only changes Turn data', () => { + const source = chatSnapshotFixture({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'working', 1, 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + }) + const process = source.nodes.values() + .find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process') + if (process === undefined + || (process.location.kind !== 'turn' && process.location.kind !== 'step') + || process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process') + const turnData = process.location.turn.data as typeof process.location.turn.data & { + set(key: 'turn-process', value: ReturnType): void + } + const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq } + turnData.set('turn-process', encodeTurnProcess(partialSpec)) + const partialProcess = { ...process, data: partialSpec } + const builder = new ChatSnapshotBuilder() + const partial = builder.replace({ + nodes: source.nodes.values().map(node => node.key === process.key ? partialProcess : node), + timeline: source.timeline, + }) + const h = makeHarness({ chat: partial, hasMore: true }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + + const beforeKeys = partial.locations.getTurn(1) + const completeSpec = { ...partialSpec, processStartSeq: 2 } + turnData.set('turn-process', encodeTurnProcess(completeSpec)) + const complete = builder.apply({ + upserts: [{ ...partialProcess, data: completeSpec }], + timeline: source.timeline, + }) + expect(complete.order).toBe(partial.order) + expect(complete.nodes).toBe(partial.nodes) + expect(complete.locations.getTurn(1)).not.toBe(beforeKeys) + expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([ + 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + + act(() => { h.set({ chat: complete, hasMore: false }) }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + }) + + it('keeps a manual expansion when the reader returns from another view', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) + document.body.appendChild(host) + try { + const first = assistant(2, 'first answer', 1, 1) + const h = makeHarness({ + nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + }) + const view = render(, { container: host }) + fireEvent.click(turnProcessControl(view.container)!) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true') + + view.rerender(
) + view.rerender() + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true') + } finally { + host.remove() + } + }) + it('withholds assistant IconActions while the turn is still running', () => { const h = makeHarness({ runningCalls: [runningCall('a')], @@ -851,8 +1524,8 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), // time 1_000 - assistant(2, 'mid-turn text'), - assistant(16, 'final answer'), + assistant(2, 'mid-turn text', 1, 1), + assistant(16, 'final answer', 1, 2), toolResult(18, 'trailing'), ], turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), @@ -860,7 +1533,7 @@ describe('ChatView', () => { }) const view = render() // The exact turn/end includes trailing tool activity after the final text. - expect(view.getAllByText(/用时 19秒/)).toHaveLength(1) + expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒') }) it('the settled footer appends first-step ttft and turn decode throughput', () => { @@ -881,7 +1554,7 @@ describe('ChatView', () => { }) const view = render() // First-step ttft (1.2s) plus 100 tokens over 5s of decode. - expect(view.getAllByText(/用时 19秒/)).toHaveLength(1) + expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒') expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1) expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1) }) @@ -1004,8 +1677,8 @@ describe('ChatView', () => { h.setChat({ nodes: [ user(1, markdown), - assistant(2, markdown), - { ...assistant(3, markdown), interrupted: true }, + assistant(2, markdown, 1, 1), + { ...assistant(3, markdown, 1, 2), interrupted: true }, ], }) }) @@ -1038,12 +1711,12 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((key: string, owner: object) => { + h.setNodeRenderer(((key: string, owner: object) => { if (key !== 'conversation.chat.node' || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null rowRenders += 1 return
- }) + }) as React.ComponentProps['renderSlot']) const view = render() expect(view.getByTestId('counting-row')).toBeTruthy() const afterMount = rowRenders @@ -1097,7 +1770,7 @@ describe('ChatView', () => { return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' ? : opts?.fallback ?? null - }) as ChatViewSlotProps['renderSlot'] + }) as React.ComponentProps['renderSlot'] const view = render() const tool = view.getByTestId('stateful-tool') const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]') @@ -1148,10 +1821,10 @@ describe('ChatView', () => { const block = toolResult(3, 'a') const h = makeHarness({ nodes: [block] }) const calls: { key: string; owner: object; entryKey?: string }[] = [] - h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { + h.setNodeRenderer(((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) }) return opts?.fallback ?? null - }) + }) as React.ComponentProps['renderSlot']) render() expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ @@ -1568,7 +2241,7 @@ describe('ChatView', () => { }) const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() - expect(fv.getByText('命令失败')).toBeTruthy() + expect(fv.getByText('指令失败')).toBeTruthy() expect(fv.getByText('失败')).toBeTruthy() // Still executing: running state with the executing copy. @@ -1585,7 +2258,7 @@ describe('ChatView', () => { nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })], }) const ov = render() - expect(ov.getByText('命令')).toBeTruthy() + expect(ov.getByText('指令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 740f473669..5315045237 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -15,6 +15,8 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' +import { decodeTurnProcess } from '../src/client/contract/turn-process.ts' import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' import { commandDefinition } from '../src/client/conversation-nodes/command.ts' @@ -29,6 +31,7 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts' import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' +import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts' import type { AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, } from '../src/client/contract/chat-nodes.ts' @@ -39,6 +42,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [ messageDefinition, requestPromptDefinition(inspectRequestPrompt), assistantDefinition, + turnProcessDefinition, toolDefinition, commandDefinition, compactionDefinition, @@ -221,6 +225,350 @@ describe('built-in conversation node Definitions', () => { expect(items[0]?.prompt.length).toBe(160) }) + it('classifies reply content separately from reasoning and Tool protocol blocks', () => { + expect(hasAssistantReplyContent([{ kind: 'text', text: ' ' }])).toBe(false) + expect(hasAssistantReplyContent([{ kind: 'reasoning', text: 'thinking' }])).toBe(false) + expect(hasAssistantReplyContent([{ kind: 'tool-call', callId: 'c', name: 'read', argsRaw: '{}' }])).toBe(false) + expect(hasAssistantReplyContent([{ kind: 'text', text: 'answer' }])).toBe(true) + expect(hasAssistantReplyContent([{ kind: 'image', attachment: {} as never }])).toBe(true) + expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true) + }) + + it('projects one reversible process window before the finalized answer', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', { + ...textMessage('context-1', 'workspace context'), + turn: 1, + step: 1, + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(4, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + at(5, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' }, + }), + at(6, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' }, + }), + ]) + const process = () => { + const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process') + return signature === undefined ? undefined : decodeTurnProcess(signature) + } + expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null }) + expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null }) + + value.append(at(7, 'tool/call', { + turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}', + })) + value.append(at(8, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-1', 'done'), + }, { surfaceOp: 'append' })) + value.append(at(9, 'step/end', { turn: 1, step: 1 })) + value.append(at(10, 'step/start', { turn: 1, step: 2 })) + value.append(at(11, 'assistant/chunk', { + turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' }, + })) + value.append(at(12, 'assistant/chunk', { + turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' }, + })) + value.flush() + expect(process()).toMatchObject({ + processStartSeq: 4, + answerAnchorSeq: null, + answerStep: null, + inlineReasoning: false, + }) + + value.append(at(13, 'llm/retry', { + retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + })) + value.flush() + expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null }) + + value.append(at(14, 'assistant/chunk', { + turn: 1, + step: 2, + chunk: { type: 'text-delta', index: 0, text: 'replacement reply' }, + })) + value.flush() + expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null }) + + value.append(at(15, 'step/end', { turn: 1, step: 2 })) + value.append(at(16, 'turn/end', { + turn: 1, + reason: { kind: 'aborted', reason: { kind: 'user' } }, + })) + value.flush() + expect(process()).toMatchObject({ answerAnchorSeq: 14.1, answerStep: 2 }) + + const recovered = assembler([ + at(20, 'turn/start', { turn: 2 }), + at(21, 'step/start', { turn: 2, step: 1 }), + at(22, 'assistant/message', { + turn: 2, step: 1, message: assistantMessage('recovered-1', 'settled reply'), + }, { surfaceOp: 'append' }), + at(23, 'step/end', { turn: 2, step: 1 }), + at(24, 'step/start', { turn: 2, step: 2 }), + at(25, 'assistant/chunk', { + turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' }, + }), + at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }), + ]) + const recoveredSignature = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process') + expect(recoveredSignature === undefined ? undefined : decodeTurnProcess(recoveredSignature)) + .toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 }) + + const partialWindow = assembler([ + at(30, 'assistant/chunk', { + turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' }, + }), + at(31, 'step/end', { turn: 3, step: 4 }), + ], true) + const partialSignature = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process') + expect(partialSignature === undefined ? undefined : decodeTurnProcess(partialSignature)) + .toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 }) + }) + + it('counts Assistant messages, Tool calls, and subagent delegations per Turn', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, step: 1, message: assistantMessage('message-1', 'checking'), + }, { surfaceOp: 'append' }), + at(4, 'tool/call', { + turn: 1, step: 1, callId: 'call-read', name: 'read', arguments: '{}', + }), + at(5, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-read', 'read done'), + }, { surfaceOp: 'append' }), + at(6, 'tool/call', { + turn: 1, step: 1, callId: 'call-subagent', name: 'subagent_fork', arguments: '{}', + }), + at(7, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-subagent', 'delegation done'), + }, { surfaceOp: 'append' }), + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('message-2', 'final answer'), + }, { surfaceOp: 'append' }), + at(11, 'step/end', { turn: 1, step: 2 }), + at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process') + expect(signature === undefined ? undefined : decodeTurnProcess(signature)).toMatchObject({ + messageCount: 1, + toolCallCount: 1, + subagentCount: 1, + }) + }) + + it('orders the opening User before its process control and later steering', () => { + const steering = textMessage('steer-1', 'change direction') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', { + ...textMessage('context-1', 'runtime context'), + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }), + at(4, 'step/start', { turn: 1, step: 1 }), + ]) + const opening = snapshot(value) + expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([ + 'user', 'context', + ]) + + value.append(at(5, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + })) + value.flush() + const running = snapshot(value) + expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([ + 'user', 'turn-process', 'context', 'assistant-step', + ]) + + value.append(at(6, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [steering], + })) + value.append(at(7, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + })) + value.append(at(8, 'user/message', steering, { surfaceOp: 'append' })) + value.append(at(9, 'step/end', { turn: 1, step: 1 })) + value.append(at(10, 'step/start', { turn: 1, step: 2 })) + value.append(at(11, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' })) + value.append(at(12, 'step/end', { turn: 1, step: 2 })) + value.append(at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } })) + value.flush() + const current = snapshot(value) + + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'user', 'turn-process', 'context', 'steering', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + }) + + it('orders a command-started Turn first steering before its process control', () => { + const steering = textMessage('command-task', 'plan this change') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [steering], + }), + at(3, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(4, 'user/message', steering, { surfaceOp: 'append' }), + at(5, 'step/start', { turn: 1, step: 1 }), + at(6, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'step/start', { turn: 1, step: 2 }), + at(9, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' }), + at(10, 'step/end', { turn: 1, step: 2 }), + at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const current = snapshot(value) + + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'steering', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + }) + + it('keeps a first human message after process evidence at its event position', () => { + const steering = textMessage('late-steering', 'change direction') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { + turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}', + }), + at(4, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-1', 'done'), + }, { surfaceOp: 'append' }), + at(5, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [steering], + }), + at(6, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(7, 'user/message', steering, { surfaceOp: 'append' }), + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' }), + at(11, 'step/end', { turn: 1, step: 2 }), + at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const current = snapshot(value) + + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'tool-call', 'steering', 'assistant-step', 'turn-tail', + ]) + }) + + it('keeps Process before pre-User Context as answer eligibility changes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', { + ...textMessage('context-1', 'runtime context'), + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + ]) + const running = snapshot(value) + expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'context', 'assistant-step', + ]) + + value.append(at(5, 'step/end', { turn: 1, step: 1 })) + value.append(at(6, 'step/start', { turn: 1, step: 2 })) + value.append(at(7, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' })) + value.flush() + const answered = snapshot(value) + expect(answered.order.map(key => answered.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'context', 'assistant-step', 'assistant-step', + ]) + + value.append(at(8, 'llm/retry', { + retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + })) + value.flush() + const retried = snapshot(value) + expect(retried.order.map(key => retried.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'context', 'assistant-step', 'model-retry', + ]) + }) + + it('establishes the answer boundary only when a streamed answer finalizes', () => { + const value = assembler([ + at(40, 'turn/start', { turn: 4 }), + at(41, 'step/start', { turn: 4, step: 1 }), + at(42, 'assistant/chunk', { + turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + at(43, 'assistant/chunk', { + turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' }, + }), + ]) + const read = () => { + const signature = snapshot(value).timeline.turns.get(4)?.data.get('turn-process') + if (signature === undefined) throw new Error('turn-process signature is unavailable') + return decodeTurnProcess(signature) + } + const streaming = read() + value.append(at(44, 'assistant/message', { + turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'), + }, { surfaceOp: 'append' })) + value.flush() + const settled = read() + + expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null }) + expect(settled.answerAnchorSeq).toBe(44) + expect(settled.answerStep).toBe(1) + }) + + it('anchors a streamed non-text answer from its block start', () => { + const value = assembler([ + at(50, 'turn/start', { turn: 5 }), + at(51, 'step/start', { turn: 5, step: 1 }), + at(52, 'assistant/chunk', { + turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' }, + }), + ]) + const current = snapshot(value) + const process = node(current, 'turn-process') + const answer = node(current, 'assistant-step') + const signature = current.timeline.turns.get(5)?.data.get('turn-process') + + expect(process?.anchorSeq).toBe(51.9) + expect(answer?.anchorSeq).toBe(52) + expect(signature === undefined ? undefined : decodeTurnProcess(signature)) + .toMatchObject({ answerAnchorSeq: null, answerStep: null }) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -638,10 +986,10 @@ describe('built-in conversation node Definitions', () => { const after = snapshot(value) expect(after.nodes).toBe(store) expect(after.nodes.get(existing?.key ?? '')).toBe(existing) - expect(after.order).toHaveLength(before.order.length + 3) + expect(after.order).toHaveLength(before.order.length + 4) expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ - 'user', 'assistant-step', 'turn-tail', - 'user', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'assistant-step', 'turn-tail', ]) }) @@ -671,7 +1019,7 @@ describe('built-in conversation node Definitions', () => { expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder) expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes) expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ - 'user', 'assistant-step', 'turn-tail', 'user', + 'user', 'turn-process', 'assistant-step', 'turn-tail', 'user', ]) }) @@ -929,6 +1277,51 @@ describe('built-in conversation node Definitions', () => { expect(node(current, 'system-prompt')?.anchorSeq).toBe(1) }) + it('keeps the initial system prompt before the opening User as Turn process state changes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), + at(4, 'user/message', { + ...textMessage('runtime-context', 'runtime facts'), + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(5, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + }), + ]) + const kinds = () => { + const current = snapshot(value) + return current.order.map(key => current.nodes.get(key)?.kind) + } + const promptKey = node(snapshot(value), 'system-prompt')?.key + + expect(kinds()).toEqual(['system-prompt', 'user', 'context']) + + value.append(at(6, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + })) + value.flush() + expect(kinds()).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', + ]) + + value.append(at(7, 'step/end', { turn: 1, step: 1 })) + value.append(at(8, 'step/start', { turn: 1, step: 2 })) + value.append(at(9, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' })) + value.append(at(10, 'step/end', { turn: 1, step: 2 })) + value.append(at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } })) + value.flush() + + expect(kinds()).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey) + }) + it('keeps an append-only later user turn in the existing system-prompt series', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -953,7 +1346,7 @@ describe('built-in conversation node Definitions', () => { expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user']) }) - it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => { + it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => { const reasons = ['change', 'resume', 'series'] as const for (const reason of reasons) { const windowedSystem = reason === 'series' ? '# Original' : '# Windowed' @@ -967,7 +1360,13 @@ describe('built-in conversation node Definitions', () => { }), ], true) - expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8) + const before = snapshot(windowed) + const prompt = node(before, 'system-prompt') + const user = node(before, 'user') + if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete') + const stableOrder = [user.key, prompt.key] + expect(prompt.anchorSeq).toBe(8) + expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder) windowed.prepend([ at(1, 'turn/start', { turn: 1 }), @@ -985,7 +1384,9 @@ describe('built-in conversation node Definitions', () => { const candidate = restored.nodes.get(key) return candidate?.kind === 'system-prompt' ? [candidate] : [] }) - expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5]) + expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 8]) + expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(8) + expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder) } }) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d4e4022e1c..cc1cf29745 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index bb5d339f0a..ffbde6bb29 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { calls.push(owner) return (
- {owner.images.map(({ attachment: image }, index) => ( - {image.name} - ))} + {owner.images.map((entry, index) => { + if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references') + const image = entry.attachment + return {image.name} + })}
) } diff --git a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx index a9f37c54bf..3da452f418 100644 --- a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx +++ b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx @@ -73,7 +73,7 @@ describe('Chat selection survives on its store seat', () => { await b.runtime.sessions.add({ id: 's1' }) const reborn = storeFor(b, 'conversation.view', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null }) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] }) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts new file mode 100644 index 0000000000..b0781e1edf --- /dev/null +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { ChatSettings } from '../src/chat-settings.ts' +import { TranscriptViewPolicy } from '../src/client/transcript-view.ts' + +describe('TranscriptViewPolicy', () => { + it('defaults to Compact and publishes explicit choices before persistence settles', () => { + const host = stubSettingsScope() + const observed: string[] = [] + let current = (): string => 'unconstructed' + const scope: typeof host.scope = { + ...host.scope, + set: (field, value) => { + observed.push(`${field}=${String(value)}:${current()}`) + return host.scope.set(field, value) + }, + } + const policy = new TranscriptViewPolicy(scope) + current = () => policy.mode.getSnapshot() + + expect(policy.mode.getSnapshot()).toBe('compact') + policy.setMode('normal') + expect(policy.mode.getSnapshot()).toBe('normal') + expect(observed).toEqual(['transcriptView=normal:normal']) + expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal') + }) + + it('adopts Host state and ignores identical writes', () => { + const host = stubSettingsScope() + const policy = new TranscriptViewPolicy(host.scope) + + host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true }) + expect(policy.mode.getSnapshot()).toBe('normal') + policy.setMode('normal') + expect(host.set).not.toHaveBeenCalled() + + host.publish({ value: { transcriptView: 'compact' }, revision: 2 }) + expect(policy.mode.getSnapshot()).toBe('compact') + }) + + it('adopts an accepted section standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true }) + expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') + }) +}) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx new file mode 100644 index 0000000000..f946a28f1a --- /dev/null +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' +import { en } from '../src/client/locale.ts' + +afterEach(cleanup) + +function emptySessions() { + return bindSnapshotSelector(createSnapshotStore({ + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + })) +} + +function emptyWorkspaces() { + return bindSnapshotSelector(createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + })) +} + +function noPendingInteraction() { + return bindSnapshotSelector(createSnapshotStore(new Map())) +} + +function mount(mode: 'normal' | 'compact' = 'compact') { + const source = createSnapshotStore(mode) + const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) + const props: TranscriptViewRowProps = { + useSessions: emptySessions(), + useSessionPendingInteraction: noPendingInteraction(), + useWorkspaces: emptyWorkspaces(), + useTranscriptView: bindSnapshotSelector(source), + setTranscriptView, + t: makeTranslate(en), + } + render() + return { setTranscriptView } +} + +describe('TranscriptViewRow', () => { + it('explains the preference and shows Compact by default', () => { + mount() + expect(screen.getByText('Conversation display')).toBeDefined() + expect(screen.getByText('Controls process content in completed turns')).toBeDefined() + expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('selects Normal and follows the mirrored value', () => { + const b = mount() + fireEvent.click(screen.getByRole('button', { name: /Compact/ })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Normal' })) + expect(b.setTranscriptView).toHaveBeenCalledWith('normal') + const trigger = screen.getByRole('button', { name: /Normal/ }) + fireEvent.click(trigger) + expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull() + }) +}) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index f819db101d..4d42320885 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -56,6 +56,9 @@ { "path": "../../session/session-stats" }, + { + "path": "../../settings/settings" + }, { "path": "../locale" }, @@ -80,6 +83,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-settings" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 07c5c18a52..a5a15451f2 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 5c39be0e97..def2baa09c 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -142,7 +142,7 @@ async function bench(opts: BenchOptions = {}) { } /** 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 }) + await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal }) } return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote } } @@ -175,7 +175,7 @@ const themeContribution = (over: Partial = {}): CommandCont }) const req = (query: string, position: 'leading' | 'inline' = 'leading') => - ({ query, position, signal: new AbortController().signal }) + ({ query, position, drilled: false, signal: new AbortController().signal }) describe('registration', () => { it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d7a0420bec..a293070d39 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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 packages/client/ui-conversation/README.md -README.md: 188a4461c1ab46e8be4f96c402c4707b1c66c415 -README.zh.md: 63356b04ef07ddd8c037d46d0ad105ed01afda80 +README.md: bcce1e77c45cfa75c6a7fe9c45a98b1036d9ea63 +README.zh.md: 1d24141945c68d7e948a730ff5b93e8d2e38e9bb diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 188a4461c1..bcce1e77c4 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,6 +40,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. + While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 63356b04ef..1d24141945 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,6 +40,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 + 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index c86c5f335e..e9e434071b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 13aa8a82e8..d6a0741200 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -336,14 +336,16 @@ export interface InputState { /** * 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. + * Created on enter; carried by adjudicated/submit-settled/sink-settled + * events; stale attempts are dropped (anti-backwash). Command attempts hold + * the single frozen in-flight slot; default-sink attempts run detached and + * concurrently. release/session teardown aborts them all, keeping every + * promise bounded. */ export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */ + /** Clipboard-projection draft captured before an optimistic default-send commit. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode @@ -365,6 +367,8 @@ export type InputEvent = | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */ | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } + /** Settlement of one optimistic default send, independent of the frozen command slot. */ + | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -376,7 +380,13 @@ export type InputEvent = 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 attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } + /** Detached default send; the shell captures its editor projection before the following commit effect. */ + | { + readonly type: 'default-sink' + readonly attempt: SubmitAttempt + readonly draft: string + readonly mode: InputSubmitMode + } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } /** * Clear the committed draft in the editor and cut undo history. A string diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index b95b1ce5b1..377a75ee00 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -28,6 +28,10 @@ export interface ComposerAttachment { id: DraftAttachmentId file: File previewUrl: string + /** Intrinsic pixel width, filled asynchronously by the intake header probe. */ + width?: number + /** Intrinsic pixel height, filled asynchronously by the intake header probe. */ + height?: number } /** Input state handed to the optional attachment presentation plugin. */ @@ -44,12 +48,35 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } -/** Durable image group handed to the optional attachment presentation plugin. */ +/** + * One image inside a message record: a durable admitted reference, or the + * local preview of a submission echo whose admission is still in flight. + */ +export type MessageImageSource = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + /** Browser-owned preview URL (lifecycle stays with the submitter). */ + readonly url: string + readonly name?: string + /** Intrinsic pixel width, when the intake probe has resolved it. */ + readonly width?: number + /** Intrinsic pixel height, when the intake probe has resolved it. */ + readonly height?: number + } + } + +/** Durable image loader with an optional synchronous cache read. */ +export type MessageImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { + peek?: (attachment: ImageAttachmentRef) => string | undefined +} + +/** Message image group handed to the optional attachment presentation plugin. */ export interface MessageImagesOwnerProps { - /** Durable image references in source order. */ - images: readonly { readonly attachment: ImageAttachmentRef }[] - /** Session-authorized image URL loader. */ - loadImage: (attachment: ImageAttachmentRef) => Promise + /** Durable references or submission-echo previews in source order. */ + images: readonly MessageImageSource[] + /** Session-authorized image URL loader for the durable arm. */ + loadImage: MessageImageLoader /** Horizontal placement inside the owning record. */ align: 'start' | 'end' } diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index ffb132cd92..6cec0a0fa1 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -214,6 +214,29 @@ export class UiConversation extends Service { return this.images.resolve(sessionId, attachment) } + /** + * Read a cached durable image URL synchronously when one is available. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference from a session event. + * @returns current preview or canonical URL, if cached. + */ + peekImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined { + return this.images.peek(sessionId, attachment) + } + + /** + * Adopt an already-displayable URL for one durable reference (see + * HistoricalImageCache.seed): the transcript node then renders it without a + * byte round-trip. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL displays. + * @param url - browser URL to adopt. + * @returns whether the cache took URL ownership. + */ + seedImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + return this.images.seed(sessionId, attachment, url) + } + /** * Canonicalize one `request/header` event against the previous prompt state. * diff --git a/packages/client/ui-conversation/src/client/conversation/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts index 602b104c1d..6fca566051 100644 --- a/packages/client/ui-conversation/src/client/conversation/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -8,7 +8,8 @@ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' interface ImageUrlEntry { readonly sessionId: SessionId readonly generation: number - readonly pending: Promise + current?: string + pending: Promise } /** Resolve durable Conversation images and release their browser URLs with Session scope. */ @@ -35,7 +36,7 @@ export class HistoricalImageCache { */ resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed')) - const key = `${sessionId}:${attachment.attachmentId}` + const key = this.key(sessionId, attachment) const cached = this.entries.get(key) if (cached !== undefined) return cached.pending const binding = this.sessions.binding(sessionId) @@ -43,28 +44,105 @@ export class HistoricalImageCache { return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`)) } this.bindScope(sessionId, binding.ctx) - const generation = this.generations.get(sessionId) ?? 0 - const pending = binding.session.readAttachment(attachment.attachmentId) + const entry: ImageUrlEntry = { + sessionId, + generation: this.generations.get(sessionId) ?? 0, + pending: Promise.resolve(''), + } + this.entries.set(key, entry) + entry.pending = this.loadCanonical(key, entry, attachment) + return entry.pending + } + + /** + * Return an already-displayable URL without starting a read. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference. + * @returns current preview or canonical URL when cached. + */ + peek(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined { + return this.entries.get(this.key(sessionId, attachment))?.current + } + + /** + * Adopt a submission preview while fetching the durable admitted bytes. + * The preview is available synchronously, then replaced and revoked when + * the canonical attachment read completes. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL temporarily displays. + * @param url - browser URL to adopt. + * @returns whether the cache took ownership. + */ + seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + if (this.disposed) return false + const key = this.key(sessionId, attachment) + if (this.entries.has(key)) return false + const binding = this.sessions.binding(sessionId) + if (binding === undefined) return false + this.bindScope(sessionId, binding.ctx) + const entry: ImageUrlEntry = { + sessionId, + generation: this.generations.get(sessionId) ?? 0, + current: url, + pending: Promise.resolve(url), + } + this.urls.add(url) + this.entries.set(key, entry) + entry.pending = this.loadCanonical(key, entry, attachment).catch((error: unknown) => { + if (this.entries.get(key) === entry && entry.current === url) { + this.entries.delete(key) + this.releaseUrl(url) + } + throw error + }) + // Seed begins the durable read before a transcript image necessarily + // mounts. Keep that legitimate no-consumer path from becoming an + // unhandled rejection; resolve() still returns the rejecting promise. + void entry.pending.catch(() => {}) + return true + } + + private key(sessionId: SessionId, attachment: ImageAttachmentRef): string { + return `${sessionId}:${attachment.attachmentId}` + } + + private loadCanonical( + key: string, + entry: ImageUrlEntry, + attachment: ImageAttachmentRef, + ): Promise { + const binding = this.sessions.binding(entry.sessionId) + if (binding === undefined) return Promise.reject(new Error(`ui-conversation: unknown session "${entry.sessionId}"`)) + return binding.session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') - if ((this.generations.get(sessionId) ?? 0) !== generation) { - throw new Error('ui-conversation image scope was released before loading completed') - } + this.assertLive(key, entry) + let url: string if (typeof URL.createObjectURL !== 'function') { - return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + url = `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } else { + const bytes = Uint8Array.from(result.value.data) + url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) } - const bytes = Uint8Array.from(result.value.data) - const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.assertLive(key, entry) this.urls.add(url) + const previous = entry.current + entry.current = url + if (previous !== undefined && previous !== url) this.releaseUrl(previous) return url }) .catch((error: unknown) => { - if (this.entries.get(key)?.generation === generation) this.entries.delete(key) + if (this.entries.get(key) === entry && entry.current === undefined) this.entries.delete(key) throw error }) - this.entries.set(key, { sessionId, generation, pending }) - return pending + } + + private assertLive(key: string, entry: ImageUrlEntry): void { + if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') + if (this.entries.get(key) !== entry + || (this.generations.get(entry.sessionId) ?? 0) !== entry.generation) { + throw new Error('ui-conversation image scope was released before loading completed') + } } private bindScope(sessionId: SessionId, scope: Context): void { @@ -81,15 +159,15 @@ export class HistoricalImageCache { for (const [key, entry] of this.entries) { if (entry.sessionId !== sessionId) continue this.entries.delete(key) - void entry.pending.then((url) => { - if (!this.urls.delete(url)) return - revokeUrl(url) - }, () => { - // Failed and invalidated loads create no browser URL. - }) + if (entry.current !== undefined) this.releaseUrl(entry.current) } } + private releaseUrl(url: string): void { + if (!this.urls.delete(url)) return + revokeUrl(url) + } + private dispose(): void { if (this.disposed) return this.disposed = true diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 67e2f28611..102b385e8c 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -53,7 +53,8 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, + MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, + UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 5716c8108f..e20a006dac 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -18,8 +18,6 @@ export interface TextRefRange { readonly start: number readonly end: number readonly trigger: '/' | '@' - /** Optional icon domain for syntax-recognizable plain references. */ - readonly appearance?: 'folder' } /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ @@ -59,7 +57,7 @@ export function scanTextRefs( const start = folder.index + (folder[1]?.length ?? 0) const end = start + token.length if (!out.some(range => range.start < end && range.end > start)) { - out.push({ start, end, trigger: '@', appearance: 'folder' }) + out.push({ start, end, trigger: '@' }) } } return out.sort((left, right) => left.start - right.start) diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css index 40f6f2d075..25b884b689 100644 --- a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css +++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css @@ -3,24 +3,11 @@ this sheet covers text-level decorations). */ /* Plain-text reference: chip family colors over the draft's own glyphs. - clone keeps rounded ends on soft-wrap fragments. */ + clone keeps rounded ends on soft-wrap fragments. Color only, no icon — + a token still carrying its trigger character is editable text; the domain + icon belongs to the settled chip alone. */ .textRef { color: var(--dsw-alias-state-business-primary); box-decoration-break: clone; -webkit-box-decoration-break: clone; } - -/* Folder references carry the domain glyph as an icon prefix (the bubble's - IconFolderClose16 asset as a currentcolor mask). The literal token text — - trigger character included — stays intact: a Lexical text node cannot - split out its trigger character the way the old backdrop overpainted it. */ -.textRef[data-ref-appearance='folder']::before { - display: inline-block; - width: 14px; - height: 14px; - margin-right: 2px; - vertical-align: -2px; - background-color: currentcolor; - content: ''; - mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20transform%3D%22translate(1.5%202.429)%22%20d%3D%22M5.05582%200.518756L4.50669%200.86654L5.05582%200.518756ZM13%209.4837L13.65%209.4837L13.65%203.53962L13%203.53962L12.35%203.53962L12.35%209.4837L13%209.4837ZM11.3264%201.86603L11.3264%201.21603L6.52313%201.21603L6.52313%201.86603L6.52313%202.51603L11.3264%202.51603L11.3264%201.86603ZM5.58054%201.34727L6.12968%200.999489L5.60495%200.170972L5.05582%200.518756L4.50669%200.86654L5.03141%201.69506L5.58054%201.34727ZM4.11323%201.23058e-13L4.11323%20-0.65L1.67359%20-0.65L1.67359%205.00699e-14L1.67359%200.65L4.11323%200.65L4.11323%201.23058e-13ZM0%201.67359L-0.65%201.67359L-0.65%209.4837L0%209.4837L0.65%209.4837L0.65%201.67359L0%201.67359ZM11.3264%2011.1573L11.3264%2010.5073L1.67359%2010.5073L1.67359%2011.1573L1.67359%2011.8073L11.3264%2011.8073L11.3264%2011.1573ZM0%209.4837L-0.65%209.4837C-0.65%2010.767%200.390308%2011.8073%201.67359%2011.8073L1.67359%2011.1573L1.67359%2010.5073C1.10828%2010.5073%200.65%2010.049%200.65%209.4837L0%209.4837ZM1.67359%205.00699e-14L1.67359%20-0.65C0.390307%20-0.65%20-0.65%200.390309%20-0.65%201.67359L0%201.67359L0.65%201.67359C0.65%201.10828%201.10828%200.65%201.67359%200.65L1.67359%205.00699e-14ZM5.05582%200.518756L5.60495%200.170972C5.28121%20-0.340193%204.71829%20-0.65%204.11323%20-0.65L4.11323%201.23058e-13L4.11323%200.65C4.27282%200.65%204.4213%200.731715%204.50669%200.86654L5.05582%200.518756ZM6.52313%201.86603L6.52313%201.21603C6.36354%201.21603%206.21507%201.13431%206.12968%200.999489L5.58054%201.34727L5.03141%201.69506C5.35515%202.20622%205.91808%202.51603%206.52313%202.51603L6.52313%201.86603ZM13%203.53962L13.65%203.53962C13.65%202.25634%2012.6097%201.21603%2011.3264%201.21603L11.3264%201.86603L11.3264%202.51603C11.8917%202.51603%2012.35%202.97431%2012.35%203.53962L13%203.53962ZM13%209.4837L12.35%209.4837C12.35%2010.049%2011.8917%2010.5073%2011.3264%2010.5073L11.3264%2011.1573L11.3264%2011.8073C12.6097%2011.8073%2013.65%2010.767%2013.65%209.4837L13%209.4837Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E") center / contain no-repeat; -} diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts index b229948eba..9f57e4c10e 100644 --- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -3,11 +3,13 @@ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): * a `/name` or `@name` token whose name is on the trigger's lexicon, and * syntax-recognizable `@dir/` folder tokens, render in the chip family - * colors. Pure derivation as before — the entity transform converts matching - * text into TextRefNode and back as edits move it in and out of match shape; - * no occurrence identity exists. + * colors. Color only, no icon: a token still carrying its trigger character + * is editable text, not a settled chip — the domain icon marks exactly the + * settled state. Pure derivation as before — the entity transform converts + * matching text into TextRefNode and back as edits move it in and out of + * match shape; no occurrence identity exists. */ -import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical' +import type { EditorConfig, LexicalEditor, SerializedTextNode } from 'lexical' import { TextNode } from 'lexical' import { registerLexicalTextEntity } from '@lexical/text' import { mergeRegister } from '@lexical/utils' @@ -16,15 +18,10 @@ import { scanTextRefs } from '../decorations.ts' import css from './composer-editor.module.css' /** JSON form of one text-ref node. */ -export type SerializedTextRefNode = Spread<{ - appearance?: 'folder' -}, SerializedTextNode> +export type SerializedTextRefNode = SerializedTextNode /** One matched plain-text reference as a styled, fully editable text node. */ export class TextRefNode extends TextNode { - /** Optional icon domain for syntax-recognizable plain references. */ - __appearance: 'folder' | undefined - /** Lexical node registry type tag. */ static override getType(): string { return 'composer-text-ref' @@ -36,7 +33,7 @@ export class TextRefNode extends TextNode { * @returns a copy carrying the same NodeKey. */ static override clone(node: TextRefNode): TextRefNode { - return new TextRefNode(node.__text, node.__appearance, node.__key) + return new TextRefNode(node.__text, node.__key) } /** @@ -45,7 +42,7 @@ export class TextRefNode extends TextNode { * @returns a fresh node. */ static override importJSON(json: SerializedTextRefNode): TextRefNode { - const node = new TextRefNode(json.text, json.appearance) + const node = new TextRefNode(json.text) node.setFormat(json.format) node.setDetail(json.detail) node.setMode(json.mode) @@ -53,22 +50,11 @@ export class TextRefNode extends TextNode { return node } - /** - * @param text - the matched token text. - * @param appearance - optional icon domain (folder tokens). - * @param key - Lexical clone-path key; absent for fresh nodes. - */ - constructor(text: string, appearance?: 'folder', key?: NodeKey) { - super(text, key) - this.__appearance = appearance - } - /** Serialize to the JSON node form. */ override exportJSON(): SerializedTextRefNode { return { ...super.exportJSON(), type: 'composer-text-ref', - ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), } } @@ -77,7 +63,6 @@ export class TextRefNode extends TextNode { const el = super.createDOM(config) el.classList.add(css.textRef ?? 'textRef') el.setAttribute('data-composer-text-ref', '') - if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance) return el } @@ -92,15 +77,6 @@ export class TextRefNode extends TextNode { } } -/** - * Folder-shape probe for one matched token (the appearance bit). - * @param token - matched token text. - * @returns 'folder' for `@dir/` shapes; undefined otherwise. - */ -function appearanceOf(token: string): 'folder' | undefined { - return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined -} - /** * Register the plain-text reference entity transform. The claim decoration * has precedence on the leading-token seat: while a command claim holds, the @@ -130,7 +106,7 @@ export function registerTextRefDecoration( editor, getMatch, TextRefNode, - node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())), + node => new TextRefNode(node.getTextContent()), ), ) } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 0b588c4fd6..e2fb81f09b 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -113,6 +113,13 @@ const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu /** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */ const HISTORY_MERGE_DELAY_MS = 1000 +/** Editor and attachment snapshot owned by one detached default send. */ +interface DetachedDraft { + readonly draft: string + readonly occurrences: readonly Occurrence[] + readonly imageIds: readonly DraftAttachmentId[] +} + /** * The per-session input facade: scoped-event application verbs + * setDraft/submit + the published InputState store, over a shell-owned @@ -144,13 +151,24 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] - /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ - private imageSendInFlight = false private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined + /** Default sends retained until admission settles or scope disposal releases their images. */ + private readonly detachedDrafts = new Map() + /** Failed default sends waiting to be restored together in submission order. */ + private readonly failedDetached = new Map() + /** Revision of the last automatic failure restoration. */ + private failedRestoreRev: number | undefined + private restoringFailures = false + private imageFlightSeq = 0 + /** Image-only sends retained until admission settles or scope disposal releases their images. */ + private readonly imageFlights = new Map() constructor(private readonly deps: SessionInputDeps) { this.editor = createEditor({ @@ -218,6 +236,10 @@ export class SessionInputShell implements SessionInput { // caret motion and subscribers do not re-render per caret move. if (projectionContentChanged(prev, this.projection)) { this.rev += 1 + if (!this.restoringFailures && this.failedRestoreRev !== undefined) { + this.failedDetached.clear() + this.failedRestoreRev = undefined + } this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText })) } const caret = this.projection.caret @@ -337,17 +359,22 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + if (this.snapshot.phase === 'plain') { const imageIds = [...this.imageIds] - this.imageSendInFlight = true - void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { - this.imageSendInFlight = false - if (this.disposed) return - if (outcome.kind === 'success') this.commitSend(imageIds) - else if (outcome.text !== undefined) this.notify('error', outcome.text) + const controller = new AbortController() + this.imageFlightSeq += 1 + const flight = this.imageFlightSeq + this.imageFlights.set(flight, { controller, imageIds }) + this.commitSend(imageIds) + void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => { + if (this.disposed || !this.imageFlights.delete(flight)) return + if (outcome.kind === 'success') return + this.restoreImages(imageIds) + if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - this.imageSendInFlight = false - if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + if (this.disposed || !this.imageFlights.delete(flight)) return + this.restoreImages(imageIds) + this.notify('error', error instanceof Error ? error.message : String(error)) }) } return @@ -534,12 +561,29 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- - /** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */ - dispose(): void { + /** + * Teardown the shell and return every browser-owned image still retained by + * the draft or an unsettled default send. + * @returns image ids the scope disposer must release. + */ + dispose(): readonly DraftAttachmentId[] { + if (this.disposed) return [] + const retained = new Set(this.imageIds) + for (const record of this.detachedDrafts.values()) { + for (const imageId of record.imageIds) retained.add(imageId) + } + for (const flight of this.imageFlights.values()) { + for (const imageId of flight.imageIds) retained.add(imageId) + flight.controller.abort() + } this.disposed = true this.dispatchRun(({ type: 'release' })) this.unregister() this.editor.setRootElement(null) + this.detachedDrafts.clear() + this.failedDetached.clear() + this.imageFlights.clear() + return [...retained] } /** Read the live input state (guard derivation reads here). */ @@ -635,25 +679,34 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each chip occurrence 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. + * missing or serialization failure rejects the detached send and restores + * its editor snapshot. Chip-free drafts skip the async detour. */ - private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { + private sinkSerialized( + attempt: SubmitAttempt, + draft: string, + mode: InputSubmitMode, + ): void { const imageIds = [...this.imageIds] + this.imageIds = [] const occurrences = this.projection.occurrences + const record = { draft, occurrences, imageIds } + this.detachedDrafts.set(attempt.seq, record) + if (this.failedRestoreRev === this.rev) { + this.failedDetached.clear() + this.failedRestoreRev = undefined + } if (occurrences.length === 0) { - this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal)) return } const inputTriggers = this.deps.inputTriggers?.() - const controller = new AbortController() void Promise.all(occurrences.map(async (o) => { if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) return { offset: o.offset, length: o.length, - text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal), + text: await inputTriggers.serializeReference(o.source, o.ref, attempt.signal), } })).then( (parts) => { @@ -668,53 +721,116 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal)) }, (error: unknown) => { - controller.abort() if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.dispatchRun(({ - type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message, - })) + this.settleDetachedFailure(attempt, message) }, ) } - /** Settle one admission attempt; successful sends consume only their captured images. */ - private settleSubmit( + /** Settle one detached default send independently of other sends. */ + private settleSink( attempt: SubmitAttempt, pending: Promise, - imageIds: readonly DraftAttachmentId[] = [], ): void { pending.then( (outcome) => { if (this.dead(attempt)) return - if (outcome.kind === 'success' && imageIds.length > 0) { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + if (outcome.kind !== 'success') { + this.settleDetachedFailure(attempt, outcome.text) + return } - this.dispatchRun(({ - type: 'submit-settled', - attempt, - ok: outcome.kind === 'success', - draft: this.projection.clipboardText, - outcome, - })) + this.detachedDrafts.delete(attempt.seq) + this.dispatchRun(({ type: 'sink-settled', attempt, ok: true, outcome })) }, (error: unknown) => { if (this.dead(attempt)) return - this.dispatchRun(({ - type: 'submit-settled', - attempt, - ok: false, - draft: this.projection.clipboardText, - message: error instanceof Error ? error.message : String(error), - })) + this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error)) }, ) } + /** Restore one failed detached send without overwriting text entered after a restoration. */ + private settleDetachedFailure(attempt: SubmitAttempt, message?: string): void { + const record = this.detachedDrafts.get(attempt.seq) + if (record === undefined) return + this.detachedDrafts.delete(attempt.seq) + this.restoreImages(record.imageIds) + this.failedDetached.set(attempt.seq, record) + if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) { + this.restoreFailedDrafts() + } + this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, ...(message === undefined ? {} : { message }) })) + } + + /** Rebuild all currently failed snapshots in submission order. */ + private restoreFailedDrafts(): void { + const records = [...this.failedDetached.entries()].sort(([a], [b]) => a - b).map(([, record]) => record) + if (records.length === 0) return + const separator = '\n\n' + let draft = '' + const occurrences: Occurrence[] = [] + for (const record of records) { + const base = draft.length + (draft === '' ? 0 : separator.length) + if (draft !== '') draft += separator + draft += record.draft + for (const occurrence of record.occurrences) { + occurrences.push({ ...occurrence, offset: base + occurrence.offset }) + } + } + this.restoringFailures = true + try { + this.editor.update(() => { + const root = $getRoot() + root.clear() + let paragraph = $createParagraphNode() + root.append(paragraph) + const appendText = (text: string): void => { + const lines = text.split('\n') + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] + if (line !== '') paragraph.append($createTextNode(line)) + if (i < lines.length - 1) { + paragraph = $createParagraphNode() + root.append(paragraph) + } + } + } + let cursor = 0 + for (const occurrence of occurrences) { + appendText(draft.slice(cursor, occurrence.offset)) + paragraph.append(new ReferenceChipNode({ + source: occurrence.source, + ref: occurrence.ref, + label: occurrence.label, + ...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }), + clipboardText: occurrence.clipboardText, + }, occurrence.invalid === true)) + cursor = occurrence.offset + occurrence.length + } + appendText(draft.slice(cursor)) + root.selectEnd() + }, { discrete: true, tag: HISTORY_MERGE_TAG }) + this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined) + this.failedRestoreRev = this.rev + } finally { + this.restoringFailures = false + } + } + + /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ + private restoreImages(imageIds: readonly DraftAttachmentId[]): void { + if (imageIds.length === 0) return + const current = new Set(this.imageIds) + const restored = imageIds.filter(id => !current.has(id)) + if (restored.length === 0) return + this.imageIds = [...restored, ...this.imageIds] + this.publish() + } + /** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */ private adjudicate(attempt: SubmitAttempt, draft: string): void { const inputTriggers = this.deps.inputTriggers?.() diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 9e53281302..2cddf23feb 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -121,8 +121,7 @@ export class InputHub implements SessionInputResolver { ] return () => { for (const off of offs) off() - const drafts = shell.snapshot.imageIds - shell.dispose() + const drafts = shell.dispose() this.shells.delete(id) const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined for (const imageId of drafts) conversation?.releaseDraftImage(imageId) diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 5f097c107a..cfc757d63b 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -1,13 +1,11 @@ /** * SubmitMachine: the pure per-session submit-plane state machine. - * Events in, effects out; zero React / DOM / cordis. Package-private — the - * SessionInput shell is the only caller and the sole executor of the - * returned effects. + * Events in, effects out; zero React / DOM / cordis. Package-private; the + * SessionInput shell owns editor state and executes the returned effects. * - * The machine owns phase, claim, and the in-flight SubmitAttempt; it never - * holds the draft. Text truth lives in the shell's Lexical editor, and every - * decision that needs the draft reads it from the event payload (claim - * integrity watch, enter snapshots, settlement suffix/re-entry decisions). + * Claimed commands occupy the frozen in-flight slot. Ordinary messages detach + * at Enter, so the editor can clear immediately and accept another message + * while earlier admissions remain in flight. */ import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts' @@ -17,13 +15,7 @@ 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`). - */ +/** Strip a claimed command token from its submit-time draft. */ function argsAfter(draft: string, token: string): string { const s = draft.trimStart() if (s.startsWith(token)) return s.slice(token.length) @@ -41,14 +33,7 @@ export interface SubmitSnapshot { readonly claim?: InputState['claim'] } -/** - * Pure submit 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. - */ +/** Pure phase, claim, and attempt owner for one Session input. */ export class SubmitMachine { private phase: InputState['phase'] = 'plain' private claim: CommandClaim | undefined @@ -57,6 +42,8 @@ export class SubmitMachine { readonly attempt: SubmitAttempt readonly controller: AbortController } | undefined + /** Ordinary sends detached from the editor, retained for settlement validation and cancellation. */ + private readonly detached = new Map() /** Read-only snapshot of the submit-plane state. */ get state(): SubmitSnapshot { @@ -77,8 +64,8 @@ export class SubmitMachine { /** * Feed one event through the machine. - * @param ev - Input event; the single write path for all submit-plane state. - * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events. + * @param ev - submit-plane event. + * @returns effects for the SessionInput shell, in execution order. */ dispatch(ev: InputEvent): readonly InputEffect[] { switch (ev.type) { @@ -88,14 +75,15 @@ export class SubmitMachine { 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 'sink-settled': return this.onSinkSettled(ev) case 'send-committed': return this.onSendCommitted() case 'release': return this.onRelease() default: return unreachable(ev) } } - /** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */ - private onDraftChanged(draft: string): InputEffect[] { + /** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */ + private onDraftChanged(draft: string): readonly InputEffect[] { if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) { this.phase = 'plain' this.claim = undefined @@ -103,26 +91,52 @@ export class SubmitMachine { return [] } - /** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */ - private onClaim(claim: CommandClaim): InputEffect[] { + /** The editor applied a claim-token replacement; busy phases refuse another claim. */ + private onClaim(claim: CommandClaim): readonly InputEffect[] { if (this.phase !== 'plain' && this.phase !== 'claimed') return [] this.claim = claim this.phase = 'claimed' return [] } - // ---- submit plane ---- - - /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { + /** Mint an attempt and controller without assigning its lifecycle owner. */ + private mintAttempt(mode: InputSubmitMode, draft: string): { + readonly attempt: SubmitAttempt + readonly controller: AbortController + } { const controller = new AbortController() this.seq += 1 - const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode } - this.inflight = { attempt, controller } - return attempt + return { + attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode }, + controller, + } } - private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] { + /** Mint the frozen command/adjudication attempt. */ + private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { + const flight = this.mintAttempt(mode, draft) + this.inflight = flight + return flight.attempt + } + + /** Mint an ordinary send that leaves the phase plain. */ + private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt { + const flight = this.mintAttempt(mode, draft) + this.detached.set(flight.attempt.seq, flight.controller) + this.claim = undefined + this.phase = 'plain' + return flight.attempt + } + + /** Default-send effects capture the sink input before the editor commit. */ + private detachedEffects(attempt: SubmitAttempt): readonly InputEffect[] { + return [ + { type: 'default-sink', attempt, draft: attempt.draftSnapshot, mode: attempt.mode }, + { type: 'commit-draft', retainSuffixOf: attempt.draftSnapshot }, + ] + } + + private onEnter(mode: InputSubmitMode, draft: string): readonly InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { const attempt = this.beginAttempt(mode, draft) @@ -136,12 +150,13 @@ export class SubmitMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft }] } - const attempt = this.beginAttempt(mode, draft) - this.phase = 'submitting' - return [{ type: 'default-sink', attempt, draft, mode }] + return this.detachedEffects(this.beginDetached(mode, draft)) } - private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { + private onAdjudicated( + attempt: SubmitAttempt, + outcome: Extract['outcome'], + ): readonly 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) { @@ -154,31 +169,22 @@ export class SubmitMachine { args: argsAfter(attempt.draftSnapshot, outcome.claim.token), }] } - // 'handled' (source dealt internally), {insert}/{text} (no enter-time span - // semantics), or a miss: all land plain; only the miss flows to the sink. - if (outcome === undefined) { - this.phase = 'submitting' - return [{ - type: 'default-sink', - attempt, - draft: attempt.draftSnapshot, - mode: attempt.mode, - }] - } this.inflight = undefined this.phase = 'plain' - return [] + if (outcome !== undefined) return [] + this.detached.set(attempt.seq, flight.controller) + return this.detachedEffects(attempt) } - private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { + private onAdjudicationFailed(attempt: SubmitAttempt, message: string): readonly 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[] { + /** Claimed command settlement retains the frozen transaction semantics. */ + private onSubmitSettled(ev: Extract): readonly InputEffect[] { const flight = this.inflight if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return [] this.inflight = undefined @@ -192,10 +198,6 @@ export class SubmitMachine { return effects } const text = ev.message ?? ev.outcome?.text - // Keep the same command claim only while the live draft still equals the - // enter-time draft; 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 (ev.draft === flight.attempt.draftSnapshot && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) { this.phase = 'claimed' @@ -206,18 +208,28 @@ export class SubmitMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */ - private onSendCommitted(): InputEffect[] { + /** Settle one ordinary send independently of current phase and other detached sends. */ + private onSinkSettled(ev: Extract): readonly InputEffect[] { + if (!this.detached.delete(ev.attempt.seq)) return [] + const text = ev.message ?? ev.outcome?.text + if (text === undefined) return [] + return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }] + } + + /** Clear after an accepted image-only send; it has no text suffix to retain. */ + private onSendCommitted(): readonly InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined return [{ type: 'commit-draft', retainSuffixOf: null }] } - private onRelease(): InputEffect[] { + private onRelease(): readonly InputEffect[] { if (this.inflight !== undefined) { this.inflight.controller.abort() this.inflight = undefined } + for (const controller of this.detached.values()) controller.abort() + this.detached.clear() this.phase = 'plain' this.claim = undefined return [] diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3ab8410590..9eaf2a82a0 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -13,13 +13,13 @@ export const zh = { 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', 'placeholder.plan': PLAN_NEXT_ACTION_ZH, - 'placeholder.default': '给智能体发消息', + 'placeholder.default': '发消息或做任务… / 调用指令 @ 文件或对话', 'placeholder.unavailable': '会话不可用', 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', - 'placeholder.hero': '描述你想要构建的内容', + 'placeholder.hero': '描述你想要构建的内容… / 调用指令 @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', - 'input.commands': '命令', + 'input.commands': '指令', 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', @@ -76,8 +76,11 @@ export const zh = { 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', + 'ask.cancelledDetail': '本轮已取消,未提交回答', 'ask.interrupted': '已中断', + 'ask.interruptedDetail': '本轮已中断,未提交回答', 'ask.answered': '{answered}/{total} 已回答', + 'ask.skipped': '未回答', 'bash.running': '运行中', 'bash.failed': '失败', 'bash.stopped': '已停止', @@ -158,10 +161,10 @@ export const en = { 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', 'placeholder.plan': PLAN_NEXT_ACTION_EN, - 'placeholder.default': 'Message the agent', + 'placeholder.default': 'Message or run a task... / commands, @ files or sessions', 'placeholder.unavailable': 'Session unavailable', 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', - 'placeholder.hero': 'Describe what you want to build', + 'placeholder.hero': 'Describe what you want to build... / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Commands', @@ -221,8 +224,11 @@ export const en = { 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', + 'ask.cancelledDetail': 'This question set was cancelled before answers were submitted.', 'ask.interrupted': 'interrupted', + 'ask.interruptedDetail': 'This question set was interrupted before answers were submitted.', 'ask.answered': '{answered}/{total} answered', + 'ask.skipped': 'Not answered', 'bash.running': 'Running', 'bash.failed': 'Failed', 'bash.stopped': 'Stopped', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6b7d94829f..2fb0c99b51 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -9,11 +9,13 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { + ISessions, PendingSubmissionRetirement, SessionFace, +} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' @@ -72,6 +74,62 @@ function browserDraftAttachment(file: File): ComposerAttachment { } } +/** + * Fill the draft's intrinsic dimensions once the browser parses the image + * header (a metadata read off the preview URL, not a full decode). Failures + * and non-browser runtimes leave them absent — consumers size those images + * from CSS constraints instead. The descriptors stay registry-owned; submit + * reads the dimensions into an immutable echo snapshot, so this late write + * does not require a store notification. + */ +function probeDimensions(attachment: ComposerAttachment): void { + if (typeof Image !== 'function') return + const probe = new Image() + probe.onload = () => { + attachment.width = probe.naturalWidth + attachment.height = probe.naturalHeight + } + probe.src = attachment.previewUrl +} + +/** Give the echo one paint opportunity without letting a throttled frame clock block admission. */ +function nextPaint(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + setTimeout(resolve, 0) + return + } + let settled = false + const finish = () => { + if (settled) return + settled = true + clearTimeout(fallback) + setTimeout(resolve, 0) + } + const fallback = setTimeout(finish, 100) + requestAnimationFrame(finish) + } else { + setTimeout(resolve, 0) + } + }) +} + +/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ +function base64Of(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const url = reader.result as string + resolve(url.slice(url.indexOf(',') + 1)) + } + reader.onerror = () => { + reject(reader.error ?? new Error('conversation: image read failed')) + } + reader.readAsDataURL(file) + }) +} + /** Unsupported browser-declared image type, localized by the UI boundary. */ export class UnsupportedImageMediaTypeError extends Error { /** Browser-declared MIME value, possibly empty. */ @@ -125,7 +183,12 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. + * Submit ordered draft images with text through one host admission. A local + * submission echo enters the session snapshot synchronously; serialization + * and the prompt round-trip start after the browser can paint it. On the + * echo's observed retirement the draft images hand their preview URLs to + * the durable image cache and leave the registry; on failure they stay + * registered so the composer can restore them. * @param session - target session. * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. @@ -144,11 +207,41 @@ export class ConversationController extends Service implements IConversation { if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) + if (session.getSnapshot().subagent !== null) { + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + const result = await session.prompt(content, mode, signal) + return result.ok ? { kind: 'success' } : { kind: 'error' } + } + let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined + const retirement = attachments.length === 0 + ? undefined + : new Promise((resolve) => { finishRetirement = resolve }) + const submission = session.beginSubmission({ + text, + images: attachments.map(attachment => ({ + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + })), + onRetire: (settlement) => { + this.settleSubmittedImages(session.sessionId, attachments, settlement) + finishRetirement?.(settlement) + }, + }) + let content: Parameters[0] + try { + await nextPaint() + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + } catch (error) { + submission.abandon() + throw error + } + const result = await session.prompt(content, mode, signal, submission.requestId) if (!result.ok) return { kind: 'error' } - this.releaseDraftImages(attachments) + if (retirement !== undefined && (await retirement).reason !== 'observed') return { kind: 'error' } return { kind: 'success' } } @@ -162,6 +255,7 @@ export class ConversationController extends Service implements IConversation { return files.map((file) => { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) return attachment }) } @@ -264,6 +358,31 @@ export class ConversationController extends Service implements IConversation { return sessions } + /** + * Settle one submission's draft images when its echo retires. Observed: + * each image leaves the registry, handing its preview URL to the durable + * image cache (seeded under the admitted reference so the transcript node + * renders immediately while the cache reads canonical bytes) or revoking it + * when the cache already holds that reference. Failed: nothing changes; + * the ids stay registered for the composer's rail restore. + */ + private settleSubmittedImages( + sessionId: SessionId, + attachments: readonly ComposerAttachment[], + retirement: PendingSubmissionRetirement, + ): void { + if (retirement.reason !== 'observed') return + const uiConversation = this.ctx.get('uiConversation') + attachments.forEach((attachment, index) => { + const live = this.draftAttachments.get(attachment.id) + if (live === undefined) return + this.draftAttachments.delete(attachment.id) + const ref = retirement.attachments[index] + if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + revokePreview(attachment.previewUrl) + }) + } + /** Convert browser files to canonical base64 prompt parts. */ private serializeImages(images: readonly File[]): Promise[0]> { return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) @@ -273,7 +392,7 @@ export class ConversationController extends Service implements IConversation { private async encodeImage(file: File): Promise { return { mediaType: imageMediaType(file.type), - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + data: await base64Of(file), ...(file.name === '' ? {} : { name: file.name }), } } diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index db5bf29baa..b4103dbda4 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -102,10 +102,14 @@ describe('Conversation inject API', () => { actions.setDraft('hello') actions.submit() - await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') }) - expect(b.sessionFake.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), - ) + // Optimistic commit clears the draft at enter; the prompt lands after the + // paint-yield inside the send pipeline. + expect(state.getSnapshot().draft).toBe('') + await vi.waitFor(() => { + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String), + ) + }) b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 20a7a0f7aa..9ef8c333dd 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -21,6 +21,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SESSION_ID, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, @@ -43,6 +44,7 @@ function fakeSession(): SessionFace { projections: { faceOf: () => createSnapshotStore(undefined) }, getSnapshot: () => snapshot.getSnapshot(), subscribe: listener => snapshot.subscribe(listener), + beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }), prompt: () => Promise.reject(new Error('unused fake Session operation')), readAttachment: () => Promise.reject(new Error('unused fake Session operation')), updateQueue: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 71aa3d6018..dee570b52a 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -25,4 +25,75 @@ describe('HistoricalImageCache', () => { await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) + + it('shows a seeded URL synchronously, replaces it with canonical bytes, and revokes both', async () => { + const revoked: string[] = [] + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:canonical') + const originalRevoke = URL.revokeObjectURL.bind(URL) + URL.revokeObjectURL = (url: string) => { revoked.push(url) } + try { + const read = Promise.withResolvers>>() + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ id: 's1', session: { readAttachment: () => read.promise } }) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) + expect(cache.peek(sessionId, attachment)).toBe('blob:seeded') + expect(cache.seed(sessionId, attachment, 'blob:duplicate')).toBe(false) + const canonical = cache.resolve(sessionId, attachment) + read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) + await expect(canonical).resolves.toBe('blob:canonical') + expect(cache.peek(sessionId, attachment)).toBe('blob:canonical') + expect(revoked).toContain('blob:seeded') + + await runtime.sessions.remove(sessionId) + await Promise.resolve() + expect(revoked).toContain('blob:canonical') + await runtime.dispose() + } finally { + created.mockRestore() + URL.revokeObjectURL = originalRevoke + } + }) + + it('revokes a seeded preview when canonical bytes cannot be read', async () => { + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ + id: 's1', + session: { + readAttachment: () => Promise.resolve({ + ok: false, + error: { code: 'attachment-error', message: 'missing', details: {} }, + } as never), + }, + }) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) + await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-error: missing') + expect(cache.peek(sessionId, attachment)).toBeUndefined() + expect(revoked).toHaveBeenCalledWith('blob:seeded') + await runtime.dispose() + } finally { + revoked.mockRestore() + } + }) + + it('refuses to seed for an unknown session', async () => { + const runtime = await SlotTestRuntime.create() + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-unknown'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + expect(cache.seed('missing' as never, attachment, 'blob:orphan')).toBe(false) + await runtime.dispose() + }) }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 6fd24ce020..9becd7f109 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -376,11 +376,28 @@ describe('image draft rail', () => { sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) - expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) + // Optimistic commit: the rail clears at submit, before the admission settles. + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) await act(async () => { settle({ kind: 'success' }) }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + }) + + it('returns an image-only draft to the rail when its admission fails', async () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + ] + const result = bench({ attachments }) + const { textarea, sink } = result + let fail!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { fail = resolve })) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) }) await vi.waitFor(() => { - expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) }) + expect(result.view.getByRole('alert').textContent).toContain('图片发送失败') }) it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { @@ -427,9 +444,9 @@ describe('Enter semantics', () => { }) it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { - expect(bench({ running: true }).placeholder).toBe('给智能体发消息') - expect(bench({ queue: [row('q-1')] }).placeholder).toBe('给智能体发消息') - expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('给智能体发消息') + expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') + expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') expect(bench({ running: true, queue: [row('q-1')], @@ -437,7 +454,7 @@ describe('Enter semantics', () => { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }, - }).placeholder).toBe('给智能体发消息') + }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') expect(bench({ running: true, queue: [row('q-1')], @@ -449,7 +466,7 @@ describe('Enter semantics', () => { running: true, queue: [row('q-1')], commandMenuOpen: true, - }).placeholder).toBe('给智能体发消息') + }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') // The steer hint intentionally outranks the plan placeholder: while it // shows, the whole-queue gesture is genuinely available in plan mode. expect(bench({ @@ -781,7 +798,7 @@ describe('running and lock semantics', () => { }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect(button.getAttribute('aria-label')).toBe('发送消息') expect(button.disabled).toBe(true) expect(interruptButton?.disabled).toBe(false) @@ -829,7 +846,7 @@ describe('running and lock semantics', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('会话不可用') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -945,7 +962,7 @@ describe('running and lock semantics', () => { it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { expect(bench({ disabled: true }).placeholder).toBe('会话不可用') const live = bench() - expect(live.placeholder).toBe('给智能体发消息') + expect(live.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') const custom = bench({ placeholder: 'Custom placeholder' }) expect(custom.placeholder).toBe('Custom placeholder') }) @@ -962,7 +979,7 @@ describe('running and lock semantics', () => { expect(editableOf(textarea)).toBe(false) expect(textarea.getAttribute('aria-haspopup')).toBe('menu') expect(textarea.getAttribute('aria-expanded')).toBe('false') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(textarea) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -992,7 +1009,7 @@ describe('running and lock semantics', () => { expect(entering.placeholder).toBe('描述你的任务以生成计划') // Pending exit: target is default again. const leaving = bench({ plan: { active: true, pending: true } }) - expect(leaving.placeholder).toBe('给智能体发消息') + expect(leaving.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话') // Owner placeholder outranks the plan swap. const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' }) expect(custom.placeholder).toBe('Custom placeholder') @@ -1153,12 +1170,14 @@ describe('decorations', () => { expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull() }) - it('a directory completion carries the folder appearance without changing its plain text', () => { + it('a directory completion decorates color-only: literal text, no icon seat', () => { const { view, shell } = bench() act(() => { shell.setDraft('see @src/components/') }) const mark = view.container.querySelector('[data-composer-text-ref]') expect(mark?.textContent).toBe('@src/components/') - expect(mark?.getAttribute('data-ref-appearance')).toBe('folder') + // The trigger character marks editable text; the domain icon is the + // settled chip's alone. + expect(mark?.getAttribute('data-ref-appearance')).toBeNull() expect(shell.snapshot.draft).toBe('see @src/components/') }) @@ -1259,7 +1278,7 @@ describe('strips and variants', () => { describe('command launcher chrome and control seats', () => { it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('命令')).toBeTruthy() + expect(view.getByLabelText('指令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Every seat dispatched, nothing rendered (render passes may repeat; the @@ -1275,7 +1294,7 @@ describe('command launcher chrome and control seats', () => { const toggleCommandMenu = vi.fn() const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) }) - const launcher = view.getByLabelText('命令') + const launcher = view.getByLabelText('指令') expect(launcher.getAttribute('aria-expanded')).toBe('false') fireEvent.click(launcher) expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) @@ -1425,7 +1444,7 @@ describe('command launcher chrome and control seats', () => { it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 8d05546540..bdb878672b 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -121,8 +121,9 @@ describe('matrix row: plain', () => { expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) - expect(shell.snapshot.phase).toBe('submitting') - await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + // The detached default send never freezes the composer. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') expect(shell.snapshot.claim).toBeUndefined() }) }) @@ -305,7 +306,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 5f07952ec6..c0319e9e36 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -99,9 +99,12 @@ describe('reference submission', () => { }) shell.submit('queue') - expect(shell.snapshot.phase).toBe('submitting') + // Optimistic commit: the composer clears at enter and stays unlocked + // while the detached flight runs. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe(`${mention} `) }) expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot).toMatchObject({ @@ -114,10 +117,10 @@ describe('reference submission', () => { }) shell.submit('queue') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.draft).toBe('') + expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) }) - expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot.occurrences).toEqual([]) expect(serializeReference).toHaveBeenCalledTimes(2) }) @@ -137,11 +140,11 @@ describe('reference submission', () => { }) chip(shell) shell.submit() + // The serializer rejection restores the optimistic commit with its chip. await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe(`${mention} `) }) expect(sink).not.toHaveBeenCalled() - expect(shell.snapshot.draft).toBe(`${mention} `) expect(shell.snapshot.occurrences).toHaveLength(1) expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', @@ -165,7 +168,9 @@ describe('reference submission', () => { shell.dispose() expect(signal?.aborted).toBe(true) expect(shell.snapshot.phase).toBe('plain') - expect(shell.snapshot.draft).toBe('send this') + // The optimistic commit stands: disposal drops the settlement, so the + // sent draft is not restored into the dying composer. + expect(shell.snapshot.draft).toBe('') }) it('retains a rejected default message without duplicating its prompt error notice', async () => { @@ -182,6 +187,25 @@ describe('reference submission', () => { expect(shell.snapshot.draft).toBe('retry this') expect(shell.notices.getSnapshot()).toBeNull() }) + + it('restores concurrent failed messages in submission order', async () => { + const settlements: Array<(outcome: SubmitOutcome) => void> = [] + const shell = new SessionInputShell({ + actx: {} as Context, + defaultSink: () => new Promise((resolve) => { settlements.push(resolve) }), + commandImages, + }) + shell.setDraft('first') + shell.submit() + shell.setDraft('second') + shell.submit() + expect(shell.snapshot.draft).toBe('') + + settlements[0]?.({ kind: 'error' }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first') }) + settlements[1]?.({ kind: 'error' }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first\n\nsecond') }) + }) }) describe('submit transaction hardening', () => { @@ -223,6 +247,24 @@ describe('submit transaction hardening', () => { expect(shell.notices.getSnapshot()).toBeNull() }) + it('aborts an unsettled image-only send and returns its image id at disposal', () => { + let signal: AbortSignal | undefined + const imageId = 'img-flight' as DraftAttachmentId + const shell = new SessionInputShell({ + actx: {} as Context, + defaultSink: (_text, _ids, _mode, received) => { + signal = received + return new Promise(() => {}) + }, + commandImages, + }) + shell.addImages([imageId]) + shell.submit() + expect(signal?.aborted).toBe(false) + expect(shell.dispose()).toEqual([imageId]) + expect(signal?.aborted).toBe(true) + }) + it('re-tracks at the caret when an insert-text splice lands (directory descent reopens the menu)', () => { const track = vi.fn() const lexicon = { getSnapshot: () => new Map(), subscribe: () => () => {} } diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 6a30ed4d8a..3cd61a0b5b 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -39,6 +39,7 @@ function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { return { sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, + pendingSubmissions: [], lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, } } diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 4633d07e20..a6582d42b8 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -102,6 +102,28 @@ describe('ConversationController', () => { await b.runtime.dispose() }) + it('releases an image removed from the rail by an unsettled optimistic send', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), + ]) + if (attachment === undefined) throw new Error('draft attachment missing') + b.shell.addImages([attachment.id]) + b.shell.submit() + expect(b.shell.snapshot.imageIds).toEqual([]) + await b.runtime.sessions.remove('s1') + expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(revoked).toHaveBeenCalledWith('blob:detached') + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + it('validates every MIME type before allocating previews', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') @@ -131,6 +153,220 @@ describe('ConversationController', () => { }) }) +describe('sendSession submission echo', () => { + /** Bench with an observable beginSubmission on the session face. */ + async function echoBench() { + const b = await bench() + const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} + const abandon = vi.fn() + const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + retire.onRetire = input.onRetire + return { requestId: 'req-echo' as never, abandon } + }) + await b.runtime.sessions.updateSessionSnapshot('s1', () => {}) + const face = b.runtime.sessions.binding('s1')!.session as unknown as Record + face['beginSubmission'] = beginSubmission + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:echo-1') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + const restore = () => { + created.mockRestore() + revoked.mockRestore() + } + return { ...b, beginSubmission, abandon, retire, revoked, restore } + } + + it('registers the echo before serialization and prompts with its identity', async () => { + const b = await echoBench() + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') + // Synchronous: the echo is registered before any encoding starts. + expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ + text: '带图', + images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], + })) + expect(b.prompt).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt).toHaveBeenCalledWith( + [ + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'a.png' }, + { type: 'text', text: '带图' }, + ], + 'queue', + undefined, + 'req-echo', + ) + // The draft stays registered until the echo's observed retirement. + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + b.retire.onRetire?.({ reason: 'observed', attachments: [] }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => { + const b = await echoBench() + try { + const seedImageUrl = vi.fn(() => true) + b.runtime.ctx.provide('uiConversation') + b.runtime.ctx.set('uiConversation', { seedImageUrl }) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + const ref = { attachmentId: 'att-1' } + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).not.toHaveBeenCalled() + // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + const b = await echoBench() + try { + b.prompt.mockResolvedValueOnce({ + ok: false, error: { code: 'attachment-error', message: 'nope', details: {} }, + } as never) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + .resolves.toEqual({ kind: 'error' }) + b.retire.onRetire?.({ reason: 'failed' }) + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.revoked).not.toHaveBeenCalled() + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('abandons the echo when encoding fails before the prompt', async () => { + const b = await echoBench() + class FailingReader { + onload: (() => void) | null = null + onerror: (() => void) | null = null + error = new Error('read failed') + readAsDataURL(): void { + queueMicrotask(() => this.onerror?.()) + } + } + vi.stubGlobal('FileReader', FailingReader) + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue')) + .rejects.toThrow('read failed') + expect(b.abandon).toHaveBeenCalledOnce() + expect(b.prompt).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('yields through the macrotask fallback where no frame clock exists', async () => { + const b = await echoBench() + vi.stubGlobal('requestAnimationFrame', undefined) + try { + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '纯文本', [], 'queue')).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '纯文本' }], 'queue', undefined, 'req-echo') + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('bounds the paint yield when the frame clock is throttled', async () => { + const b = await echoBench() + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)) + try { + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '后台标签', [], 'queue') + expect(b.prompt).not.toHaveBeenCalled() + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '后台标签' }], 'queue', undefined, 'req-echo') + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('sends a subagent continuation without registering an unobservable echo', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + const snapshot = session.getSnapshot() + const beginSubmission = vi.spyOn(session, 'beginSubmission') + vi.spyOn(session, 'getSnapshot').mockReturnValue({ + ...snapshot, + subagent: { + address: { parentSessionId: 'parent', childSessionId: 'child', mode: 'continuable' } as never, + }, + }) + const prompt = vi.spyOn(session, 'prompt').mockResolvedValue({ ok: true, value: { accepted: true } }) + await expect(b.root.sendSession(session, '继续', [], 'queue')).resolves.toEqual({ kind: 'success' }) + expect(beginSubmission).not.toHaveBeenCalled() + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined) + await b.runtime.dispose() + }) +}) + +describe('draft image dimension probe', () => { + it('fills intrinsic dimensions from the header probe and skips runtimes without Image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:probe') + class InstantImage { + onload: (() => void) | null = null + naturalWidth = 0 + naturalHeight = 0 + set src(_value: string) { + this.naturalWidth = 640 + this.naturalHeight = 480 + this.onload?.() + } + } + vi.stubGlobal('Image', InstantImage) + try { + const [probed] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), + ]) + expect(probed).toMatchObject({ width: 640, height: 480 }) + vi.stubGlobal('Image', undefined) + const [unprobed] = b.root.createDraftImages([ + new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), + ]) + expect(unprobed?.width).toBeUndefined() + } finally { + vi.unstubAllGlobals() + created.mockRestore() + } + await b.runtime.dispose() + }) +}) + describe('InputHub queue steering (empty-draft accelerated Enter)', () => { const row = (id: string): QueuedMessage => ({ id: id as never, diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 1230444cd5..af1e72f47e 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -60,7 +60,8 @@ describe('submit-machine: plain × enter', () => { expect(sink.draft).toBe('hello') expect(sink.mode).toBe('queue') expect(sink.attempt.draftSnapshot).toBe('hello') - expect(m.state.phase).toBe('submitting') + expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('hello') + expect(m.state.phase).toBe('plain') }) it('retains an explicit steer mode on the default sink effect', () => { @@ -122,7 +123,8 @@ describe('submit-machine: adjudication outcomes', () => { const sink = effectAt(fx, 0, 'default-sink') expect(sink.draft).toBe('/unknown thing') expect(sink.mode).toBe('steer') - expect(m.state.phase).toBe('submitting') + expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('/unknown thing') + expect(m.state.phase).toBe('plain') }) it("'handled' lands plain with zero effects (popup shell path)", () => { @@ -315,7 +317,7 @@ describe('submit-machine: per-session isolation', () => { expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello') a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' }) expect(a.state.phase).toBe('plain') - expect(b.state.phase).toBe('submitting') + expect(b.state.phase).toBe('plain') }) }) @@ -338,7 +340,7 @@ describe('decorations: scanTextRefs', () => { it('recognizes directory paths independently of the dynamic lexicon', () => { const out = scanTextRefs('see @src/x/ now', new Map()) - expect(out).toEqual([{ start: 4, end: 11, trigger: '@', appearance: 'folder' }]) + expect(out).toEqual([{ start: 4, end: 11, trigger: '@' }]) }) it('names off the lexicon do not match; triggers are routed per lexicon list', () => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 83db7573d4..60f05a20e3 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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 packages/client/ui-deliverables/README.md -README.md: e5f9bf2a4ccbf6218a714762bc34a1a443c63022 -README.zh.md: 420a1e28a2893ca41cf24b720b2fbe416b1a38f6 +README.md: 04f9ff96c1573eb66a64de1d174e257c3a3608a2 +README.zh.md: f960d3da993fee3166486d8407c32e4f18278153 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index e5f9bf2a4c..04f9ff96c1 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -25,7 +25,7 @@ This package renders the deliverables row a finished turn ends with — the file ## Use this package -Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; a **Show in folder** action opens the session workspace when the page is loopback and the Host reports it can open paths. +Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; when the row first appears, it queries `session.canOpenWorkspacePath()`, and a **Show in folder** action opens the session workspace only when the page is loopback and that query succeeds with `true`. ### The row @@ -87,7 +87,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path. -- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`. +- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the Session Controller's `nativeOpen: false`. ### Dev Note diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 420a1e28a2..f960d3da99 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经宿主打开器打开文件,相对路径按会话 cwd 解析;页面为 loopback 且宿主报告可打开路径时,**在文件夹中显示**动作会打开会话工作区。 +与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经 Host 打开器打开文件,相对路径按会话 cwd 解析;该行首次显示时会查询 `session.canOpenWorkspacePath()`,只有页面为 loopback 且查询成功返回 `true` 时,**在文件夹中显示**动作才会打开会话工作区。 ### 该行 @@ -87,7 +87,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。 -- **原生文件夹交接以宿主桌面为目标**——经非 loopback 权威访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端宿主看似 loopback 本地,部署必须为网关设置 `nativeOpen: false`。 +- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。 ### 开发备注 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 30b6de4e7f..a977a5e609 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", @@ -47,6 +48,7 @@ }, "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", @@ -58,8 +60,10 @@ "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 6841e734e7..b3f0f04c0a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,6 +1,5 @@ -import { useLayoutEffect, useRef, useState } from 'react' -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -44,9 +43,11 @@ export function fitProducedFiles( export interface ProducedFilesInjected { /** Whether the browser itself is connected over loopback. */ isLoopback: boolean + /** Load the opener capability when this row first reaches the page. */ + ensureWorkspacePathOpen(): void hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current generation's Session workspace opener capability. */ + workspacePathOpen: HostObservable } } @@ -65,9 +66,10 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string { * @returns The produced-files row. */ export function ProducedFiles({ - matched: paths, openFile, isLoopback, useHostDescription, t, + matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t, }: ProducedFilesProps) { - const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true) + useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen]) + const hostCanOpenPath = useWorkspacePathOpen(available => available === true) const canOpenPath = isLoopback && hostCanOpenPath const limit = Math.min(paths.length, SHOWN_LIMIT) const [shownCount, setShownCount] = useState(limit) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 0e8767016e..3be17e7da4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,8 @@ */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -30,7 +32,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'connection'] +export const inject = ['slots', 'locale', 'uiConversation', 'connection', 'remote', 'remote.session'] /** * Client plugin body: register the dictionaries and the turn-tail entry. @@ -38,6 +40,34 @@ export const inject = ['slots', 'locale', 'uiConversation', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle + const workspacePathOpen = createSnapshotStore(undefined) + let requestedWorkspacePathOpen = false + let capabilityRevision = 0 + let pendingCapability: Promise | undefined + const loadWorkspacePathOpen = (): void => { + if (pendingCapability !== undefined) return + const revision = capabilityRevision + const pending = ctx.remote.session.canOpenWorkspacePath() + .then((result) => { + if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value) + }, () => { + if (revision === capabilityRevision) workspacePathOpen.set(false) + }) + .finally(() => { + if (pendingCapability === pending) pendingCapability = undefined + }) + pendingCapability = pending + } + const ensureWorkspacePathOpen = (): void => { + requestedWorkspacePathOpen = true + if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen() + } + ctx.on('connection/reset', () => { + capabilityRevision++ + pendingCapability = undefined + workspacePathOpen.set(undefined) + if (requestedWorkspacePathOpen) loadWorkspacePathOpen() + }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -48,7 +78,8 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => ({ isLoopback: connection.isLoopback, - hooks: { hostDescription: connection.hostDescription }, + ensureWorkspacePathOpen, + hooks: { workspacePathOpen }, }), }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9117fa3925..5a0043971e 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -22,7 +22,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { - fitProducedFiles, ProducedFiles, type ProducedFilesProps, + fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps, } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, @@ -403,13 +403,11 @@ describe('ProducedFiles row', () => { const capability = ( canOpenPath: boolean | undefined, isLoopback = true, - ): Pick => { - const description = canOpenPath === undefined - ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } + ): Pick => { return { isLoopback, - useHostDescription: selector => selector(description), + ensureWorkspacePathOpen: () => {}, + useWorkspacePathOpen: selector => selector(canOpenPath), } } @@ -580,14 +578,17 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} } + const generation = { getSnapshot: () => undefined, subscribe: () => () => {} } ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription, + generation, } as never) // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) + const session = { + canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), + } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() @@ -595,7 +596,16 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } }) + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + expect(injected.isLoopback).toBe(false) + expect(typeof injected.ensureWorkspacePathOpen).toBe('function') + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + ctx.emit('connection/reset') + injected.ensureWorkspacePathOpen() + await vi.waitFor(() => { + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) + }) + injected.ensureWorkspacePathOpen() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -617,4 +627,52 @@ describe('plugin registration', () => { // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) + + it('queries the workspace opener lazily and replaces stale results after reconnect', async () => { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + new UiConversation(ctx, { binding: () => undefined } as never) + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + } as never, () => null) + ctx.provide('connection', { + isLoopback: true, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) + const first = Promise.withResolvers<{ ok: true; value: boolean }>() + const second = Promise.withResolvers<{ ok: true; value: boolean }>() + const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>() + const capability = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(staleFailure.promise) + .mockRejectedValueOnce(new Error('offline')) + const session = { canOpenWorkspacePath: capability } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = ctx.slots.entries('conversation.chat.turnTail')[0] + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + + injected.ensureWorkspacePathOpen() + injected.ensureWorkspacePathOpen() + expect(capability).toHaveBeenCalledOnce() + ctx.emit('connection/reset') + expect(capability).toHaveBeenCalledTimes(2) + first.resolve({ ok: true, value: false }) + await Promise.resolve() + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + second.resolve({ ok: true, value: true }) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) }) + + ctx.emit('connection/reset') + ctx.emit('connection/reset') + staleFailure.reject(new Error('stale offline')) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) + await fiber.dispose() + }) }) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 51e6d50b5d..9c29b3efdc 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -17,6 +20,9 @@ { "path": "../locale" }, + { + "path": "../store" + }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 2d0003c1e8..90cd0ce667 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 986e38be54..9bb352e7cb 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index d7772e006a..2603c648b3 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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 packages/client/ui-goal/README.md -README.md: 29d1c0930869232527cdbfbb091f3d2126db68e9 -README.zh.md: 45f5334ef3988d5c64e0707902609a2b21d46368 +README.md: 05c44fee27f0500c6a5d53496fde3d447edbf424 +README.zh.md: f280356eb37b364f49851ca24d73370eb62e6795 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 29d1c09308..05c44fee27 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `命令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 45f5334ef3..f280356eb3 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -27,9 +27,9 @@ kind: "package-reference" 与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。active 的 goal 提供暂停动作;paused 的提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 -### 命令输入气泡 +### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `命令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -条带是投影模式:活目标经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新),因此插件不持有领域 store、不设刷新链、不挂事件监听。注入面只携带四个变更动词,经 `ctx.remote.goals` 调用;每个动词在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。命令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。 +条带是投影模式:活目标经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新),因此插件不持有领域 store、不设刷新链、不挂事件监听。注入面只携带四个变更动词,经 `ctx.remote.goals` 调用;每个动词在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。指令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。
diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index a14e25110e..1bd3457b73 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/src/client/locales.ts b/packages/client/ui-goal/src/client/locales.ts index 5fd6411573..055991e277 100644 --- a/packages/client/ui-goal/src/client/locales.ts +++ b/packages/client/ui-goal/src/client/locales.ts @@ -6,7 +6,7 @@ export const zh = { 'phase.paused': '已暂停的目标', 'phase.blocked': '受阻的目标', 'objective.aria': '目标内容', - 'commandInput.aria': '命令输入', + 'commandInput.aria': '指令输入', 'action.save': '保存目标', 'action.cancel': '取消编辑', 'action.pause': '暂停目标', diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d8fc191f84..6c8c1e34cc 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -129,7 +129,7 @@ describe('goal command input projection', () => { t, } as unknown as Parameters[0] const view = render() - const bubble = view.getByRole('group', { name: '命令输入' }) + const bubble = view.getByRole('group', { name: '指令输入' }) expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index baf9c316c9..23b3ddafb1 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/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 packages/client/ui-input-trigger/README.md -README.md: 1760881ba84492d967d405a43da179f5032763e1 -README.zh.md: 7a6e127c12a0f2f1f0d9c95021bb0d41ddd17e7b +README.md: 1f316229327d33f48e0950a2c27e143c6c991f99 +README.zh.md: 324541120406eac8677afcfda0967540dd74848e diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 1760881ba8..1f31622932 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu ### Keyboard and mouse -The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives. +The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`. ----- @@ -39,7 +39,7 @@ The composer surface keeps focus while the menu is open: rows pick on mousedown,
Implementation internals — click to expand -`src/core/` is the pure core — trigger detection, menu reduction, and exact match, with zero React/DOM/cordis — while `src/client/service.ts` wires the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources dropping silently with a console record), and the pick paths. One `InputTriggerController` resolves per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. A source is warmed into every session controller it can reach; sources whose `lexicon` rolls change after warm implement `subscribeLexicon` and the controller re-polls on each notification. `MenuView` self-registers into `conversation.input.overlay` (list kind, session scope) and renders null while closed. The overlay SlotMap merge lives here because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. +`src/core/` is the pure core — trigger detection, menu reduction, and exact match, with zero React/DOM/cordis — while `src/client/service.ts` wires the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources dropping silently with a console record), and the pick paths. One `InputTriggerController` resolves per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. A source is warmed into every session controller it can reach; sources whose `lexicon` rolls change after warm implement `subscribeLexicon` and the controller re-polls on each notification. `MenuView` self-registers into `conversation.input.overlay` (list kind, session scope) and renders null while closed. The `listbox` role sits on its scrolling viewport rather than the bounded shell, because a breadcrumb header is not an option and a listbox may not carry one; crumbs ride their own snapshot store beside the menu store, so the frozen reducer stays unaware of them. The overlay SlotMap merge lives here because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import.
diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 7a6e127c12..3245411204 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 键盘与鼠标 -菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。 +菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。 ----- @@ -39,7 +39,7 @@ kind: "package-reference"
实现细节——点击展开 -`src/core/` 是纯内核——触发器检测、菜单归约与精确匹配,零 React/DOM/cordis——而 `src/client/service.ts` 把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代、失败的 source 静默丢弃并留一条 console 记录)与 pick 路径上。每个会话 scope 各解析一个 `InputTriggerController`(`sessionOf`);对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 会被预热进它能触达的每个会话 controller;`lexicon` 名录在预热后变化的 source 实现 `subscribeLexicon`,controller 每收到通知就重拉。`MenuView` 自注册进 `conversation.input.overlay`(列表类,会话 scope),菜单关闭期间渲染 null。overlay 的 SlotMap 合并放在本包,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。 +`src/core/` 是纯内核——触发器检测、菜单归约与精确匹配,零 React/DOM/cordis——而 `src/client/service.ts` 把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代、失败的 source 静默丢弃并留一条 console 记录)与 pick 路径上。每个会话 scope 各解析一个 `InputTriggerController`(`sessionOf`);对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 会被预热进它能触达的每个会话 controller;`lexicon` 名录在预热后变化的 source 实现 `subscribeLexicon`,controller 每收到通知就重拉。`MenuView` 自注册进 `conversation.input.overlay`(列表类,会话 scope),菜单关闭期间渲染 null。`listbox` 角色落在其滚动视口而非有界外壳上,因为面包屑头部不是选项,listbox 也不得承载它;面包屑走菜单 store 之外的独立快照 store,冻结的归约器因此对它一无所知。overlay 的 SlotMap 合并放在本包,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。
diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index d87637ed67..6114813aa3 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index f93bb2b1a5..3ef63823e5 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -1,13 +1,11 @@ .menu { position: absolute; bottom: calc(100% + 4px); + /* The overlay anchor is exactly the composer card's width; both insets pin + the menu edge to edge with border and padding kept inside. */ left: 0; + right: 0; z-index: 100; - min-width: min(260px, 100%); - /* 537 is the design cap; the 100% clamp keeps the menu inside the composer - card when a narrow viewport shrinks the card below the cap (the overlay - anchor is exactly the card's width). */ - max-width: min(537px, 100%); /* Height cap: the 320px design maximum, clamped at runtime to the space * above the composer (inline max-height set in MenuView.tsx). */ max-height: 320px; @@ -49,7 +47,8 @@ text-align: left; } -.item:hover, +/* One shared highlight: pointer motion parks it here through the hover event, + so there is no separate :hover tint competing with the keyboard's. */ .item.active { background: var(--dsw-alias-interactive-bg-hover); } @@ -95,19 +94,54 @@ color: var(--dsw-alias-label-tertiary); } -/* Trailing descent affordance on drillable rows (directories): a quiet - chevron that brightens on its own hover, separate from the row pick. */ +/* Trailing seat on drillable rows (directories): the Tab keycap hint plus the + drill chevron, revealed while the row holds the shared highlight. */ +.trailing { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +/* Hint naming the keyboard twin of the chevron — a caption line plus a Tab + keycap: Tab drills the highlighted row. Hidden at rest so idle rows stay + quiet. */ +.drillHintText { + display: none; + color: var(--dsw-alias-label-caption); + font-size: 11px; + line-height: 18px; + white-space: nowrap; +} + +.drillHint { + display: none; + padding: 0 5px; + border-radius: 4px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-caption); + font-family: inherit; + font-size: 11px; + line-height: 18px; +} + +.item.active .drillHintText, +.item.active .drillHint { + display: inline-flex; +} + +/* Descent affordance: a quiet chevron that brightens on its own hover, + separate from the row pick. */ .drill { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; - margin-left: auto; border-radius: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 1; + /* label-caption, the quiet tint the composer's access-mode chevron uses. */ + color: var(--dsw-alias-label-caption); } .drill:hover { @@ -124,13 +158,75 @@ color: var(--dsw-alias-label-tertiary); } -/* Pending-source row: same cell metrics, dimmed label. */ -.loading { +/* Pending-source skeleton rows: item cell metrics around a breathing bar + (deepsuite Skeleton text variant: 2s color pulse; here the single + bg-skeleton token pulses through opacity instead of a second color). */ +.skeletonRow { display: flex; align-items: center; + /* border-box so min-height matches the .item buttons, whose UA default + box-sizing already counts the same 8px paddings inside their 40px. */ + box-sizing: border-box; min-height: 40px; padding: 8px 10px; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-dimmed); +} + +.skeletonBar { + height: 20px; + border-radius: 4px; + background: var(--dsw-alias-bg-skeleton); + animation: dsh-menu-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +@keyframes dsh-menu-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +/* Breadcrumb header of a drilled source: pinned outside .viewport so the + descent stays reversible while the candidate list scrolls under it. */ +.crumbs { + flex: none; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + padding: 4px 6px 6px; + margin-bottom: 2px; + border-bottom: 1px solid var(--dsw-alias-border-inverted); +} +/* Crumbs sit outside the listbox and take no keyboard highlight, so unlike + .item they carry their own :hover tint with nothing to compete with. */ +.crumb { + flex: 0 1 auto; + max-width: 40%; + overflow: hidden; + padding: 2px 6px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font-family: inherit; + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} +.crumb:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} +/* The step the list is already showing reads as a label, not an action. */ +.crumbCurrent, +.crumbCurrent:hover { + background: transparent; + color: var(--dsw-alias-label-primary); + cursor: default; +} +.crumbSeparator { + flex: none; + display: inline-flex; + color: var(--dsw-alias-label-caption); } diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 543f76eda8..7bdc0a76d2 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -2,14 +2,15 @@ * Trigger candidate menu: renders the InputTriggerService menu store into the * conversation.input.overlay anchor. Closed state renders null (the overlay * slot stays mounted); groups render in roster order under localized title - * rows, pending groups as a loading row; pointer picks route back through + * rows, pending groups as two skeleton rows; pointer picks route back through * the service (combobox pattern — focus never leaves the textarea, so rows * are mousedown-handled and the highlight is exposed via - * aria-activedescendant on the listbox). + * aria-activedescendant on the listbox). A source publishing crumbs gets a + * breadcrumb header pinned above the scrolling list. */ import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' import clsx from 'clsx' -import { useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -31,11 +32,15 @@ function optionId(source: string, index: number): string { * @param props - injected face (the menu store and the pick route); `t` rides the standard locale seat. * @returns the dropdown while open; null while closed. */ -export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { +export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t }: MenuViewProps) { const state = useSyncExternalStore( fn => menu.subscribe(fn), () => menu.getSnapshot(), ) + const crumbs = useSyncExternalStore( + fn => headers.subscribe(fn), + () => headers.getSnapshot(), + ) const listRef = useRef(null) // The list is bottom-anchored above the composer; clamp the design cap to // the space above it, re-measured on every store update (the anchor moves @@ -65,15 +70,40 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { }, [state.open, onDismiss]) if (!state.open) return null return ( -
-
+ // The listbox role sits on the scrolling viewport, not this shell: a + // breadcrumb header is not an option, and a listbox may not carry one. +
+ {state.groups.map((group) => { + const trail = crumbs.get(group.source) + return trail === undefined ? null : ( + + ) + })} +
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0) ? null : ( @@ -85,7 +115,12 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { ? null :
{t(group.source as MenuKey)}
} {group.status === 'pending' - ?
{t('loading')}
+ ? ( +
+
+
+
+ ) : group.items.map((item, index) => { const active = highlight !== null && highlight.source === group.source && highlight.index === index return ( @@ -106,24 +141,38 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { ev.preventDefault() onPick(group.source, index) }} + // mousemove, not mouseenter: real pointer motion moves the + // shared highlight; keyboard scrolling rows under a resting + // pointer must not steal it back. + onMouseMove={active ? undefined : () => { onHover(group.source, index) }} > - {item.icon !== undefined && {item.icon}} + {item.icon !== undefined && ( + + + + )} {item.name} {item.description !== undefined && {item.description}} {item.drill === true && ( - { - ev.preventDefault() - ev.stopPropagation() - onPick(group.source, index, 'drill') - }} - > - › + + {/* Visual hint only: Tab drills the highlighted row (the + keyboard twin of the chevron, which owns the aria label). */} + {t('drill.hint')} + {t('drill.key')} + { + ev.preventDefault() + ev.stopPropagation() + onPick(group.source, index, 'drill') + }} + > + + )} diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 71827214bd..5d3e1bf0b3 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -17,7 +17,8 @@ import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' import type { - ClientSessionContext, InputTriggerSource, PickAction, SubmitEnvelope, TriggerChar, TriggerGuard, + ClientSessionContext, InputTriggerCandidate, InputTriggerCrumb, InputTriggerSource, PickAction, + SubmitEnvelope, TriggerChar, TriggerGuard, } from '../types.ts' /** Roster access the controller borrows from the root service (registration order preserved). */ @@ -50,6 +51,14 @@ export class InputTriggerController { * for the launcher's expanded state without owning a second menu model. */ readonly launcher: SnapshotStore = createSnapshotStore(null) + /** + * Crumbs published by each header-bearing source for the open menu, keyed + * by source name. A snapshot store like {@link InputTriggerController.launcher}: + * the answer changes with every hit, and render-side consumers subscribe + * instead of re-polling sources during a render. + */ + readonly headers: SnapshotStore> = + createSnapshotStore>(new Map()) /** * Aggregated hot reference lexicon, grouped by trigger (plain-text-reference decision; * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): @@ -65,6 +74,8 @@ export class InputTriggerController { /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */ private hit: TriggerHit | null = null + /** Whether the open menu was reached by a drill pick; cleared with the menu. */ + private drilled = false private fetch: AbortController | null = null private disposed = false /** Per-source lexicon unsubscribers (sources without the hook never enter). */ @@ -119,6 +130,7 @@ export class InputTriggerController { this.menu.set(seedGroups(this.menu.getSnapshot(), roster)) } this.reduce({ type: 'hit', hit }) + this.refreshHeaders(hit, roster) this.fetchCandidates(hit, roster) } @@ -146,6 +158,7 @@ export class InputTriggerController { this.launcher.set(source) this.menu.set(seedGroups(this.menu.getSnapshot(), [match])) this.reduce({ type: 'hit', hit }) + this.refreshHeaders(hit, [match]) this.fetchCandidates(hit, [match]) } @@ -165,17 +178,36 @@ export class InputTriggerController { if (candidate === undefined) return const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source) if (src === undefined) return - const outcome = src.onPick({ - candidate, - session: this.project(), - position: hit.position, - via: 'menu', - action, - span: hit.span, - }) - this.stopFetch() - this.reduce({ type: 'close' }) - this.execute(outcome, hit.span) + this.settle(src, candidate, hit, action) + } + + /** + * Pointer pick on one crumb of a source's menu header: route it through the + * same drill path a folder row takes, so returning to a step and descending + * into one share one outcome. + * @param source - source (group) name. + * @param index - crumb index within that source's published header. + */ + pickCrumb(source: string, index: number): void { + const hit = this.hit + if (this.disposed || !this.menu.getSnapshot().open || hit === null) return + const crumb = this.headers.getSnapshot().get(source)?.[index] + if (crumb === undefined || crumb.current === true) return + const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source) + if (src === undefined) return + this.settle(src, { name: crumb.label, value: crumb.value }, hit, 'drill') + } + + /** + * Pointer hover from MenuView: park the shared highlight on the hovered + * candidate (keyboard `move` and pointer hover drive one highlight — + * last input wins). + * @param source - source (group) name. + * @param index - candidate index within the group. + */ + hover(source: string, index: number): void { + if (this.disposed) return + this.reduce({ type: 'hover', source, index }) } /** @@ -404,6 +436,7 @@ export class InputTriggerController { query: hit.query, quoted: hit.quoted, position: hit.position, + drilled: this.drilled, signal: controller.signal, }) .then( @@ -425,6 +458,69 @@ export class InputTriggerController { this.fetch = null } + /** + * Run one candidate (or crumb) through its source and apply the outcome. + * + * A drill is the one pick that leaves the menu open, so it is also the one + * that records how the next query was reached; every other pick closes the + * menu, which clears that record. + * @param src - the owning source. + * @param candidate - the picked candidate, or a crumb projected as one. + * @param hit - the authoritative hit supplying position and span CAS. + * @param action - settling pick or drill. + */ + private settle( + src: InputTriggerSource, + candidate: InputTriggerCandidate, + hit: TriggerHit, + action: PickAction, + ): void { + const outcome = src.onPick({ + candidate, + session: this.project(), + position: hit.position, + via: 'menu', + action, + span: hit.span, + }) + this.stopFetch() + this.reduce({ type: 'close' }) + const applied = this.execute(outcome, hit.span) + // Set after the close above, so the reducer's own teardown cannot clear + // it, and only when the descent text actually landed: a refused edit + // (stale draft revision, or no listener) leaves the draft where it was, + // and a header over that draft would name a directory nobody descended + // into while hiding the locations its rows still need. + this.drilled = action === 'drill' && applied + } + + /** Re-poll every header-bearing source in the hit roster and publish their crumbs. */ + private refreshHeaders(hit: TriggerHit, roster: readonly InputTriggerSource[]): void { + const projection = this.project() + const crumbs = new Map() + for (const src of roster) { + if (src.header === undefined) continue + let published: readonly InputTriggerCrumb[] | undefined + try { + published = src.header(projection, { query: hit.query, quoted: hit.quoted, drilled: this.drilled }) + } catch (error) { + // A faulty source drops silently with a console record (the + // candidate-fetch failure policy); a header is decoration and must + // not take down the menu that carries the candidates. + console.error(`[ui-input-trigger] source "${src.name}" header failed:`, error) + continue + } + if (published === undefined || published.length === 0) continue + crumbs.set(src.name, published) + } + this.setHeaders(crumbs) + } + + private setHeaders(next: ReadonlyMap): void { + if (this.headers.getSnapshot().size === 0 && next.size === 0) return + this.headers.set(next) + } + private clearLauncher(): void { if (this.launcher.getSnapshot() !== null) this.launcher.set(null) } @@ -433,6 +529,9 @@ export class InputTriggerController { const cur = this.menu.getSnapshot() const next = menuReduce(cur, ev) if (next !== cur) this.menu.set(next) - if (!next.open) this.clearLauncher() + if (next.open) return + this.clearLauncher() + this.drilled = false + this.setHeaders(new Map()) } } diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index c79f3d010d..4860b5ce9d 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -23,9 +23,10 @@ export type { MenuViewProps } from './MenuView.tsx' export type { MenuKey } from './locales.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, - CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, - ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitEnvelope, - SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, + CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia, + ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick, + InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, + TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' export type { InputTriggerServiceContract } from './contract.ts' @@ -74,7 +75,10 @@ export function apply(ctx: ClientContext): void { const controller = inputTriggers.sessionOf(actx) return { menu: controller.menu, + headers: controller.headers, onPick: (source, index, action) => { controller.pick(source, index, action) }, + onCrumb: (source, index) => { controller.pickCrumb(source, index) }, + onHover: (source, index) => { controller.hover(source, index) }, onDismiss: () => { controller.dismiss() }, } }, diff --git a/packages/client/ui-input-trigger/src/client/locales.ts b/packages/client/ui-input-trigger/src/client/locales.ts index f8d5dab806..704edbd633 100644 --- a/packages/client/ui-input-trigger/src/client/locales.ts +++ b/packages/client/ui-input-trigger/src/client/locales.ts @@ -1,16 +1,19 @@ /** * `slash.menu` namespace dictionaries: group titles keyed by source name * (the lookup chain returns the key itself, so an unknown source shows its - * raw name), the pending row, and the listbox aria label. + * raw name), the pending row, and the listbox and header aria labels. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'command': '命令', + 'command': '指令', 'skill': '技能', 'subagent': '子智能体', 'loading': '正在加载…', 'drill.aria': '进入目录', + 'drill.hint': '进入目录', + 'drill.key': 'Tab', + 'crumbs.aria': '目录导航', 'suggestions.aria': '触发候选建议', } satisfies Record @@ -24,5 +27,8 @@ export const en = { 'subagent': 'Subagents', 'loading': 'Loading…', 'drill.aria': 'Browse folder', + 'drill.hint': 'Browse folder', + 'drill.key': 'Tab', + 'crumbs.aria': 'Folder navigation', 'suggestions.aria': 'Trigger suggestions', } satisfies Record diff --git a/packages/client/ui-input-trigger/src/client/slots.ts b/packages/client/ui-input-trigger/src/client/slots.ts index 85265f8d72..d07fdc0e65 100644 --- a/packages/client/ui-input-trigger/src/client/slots.ts +++ b/packages/client/ui-input-trigger/src/client/slots.ts @@ -1,6 +1,6 @@ /** Slash-menu props for the Conversation-owned input overlay. */ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { PickAction } from '../types.ts' +import type { InputTriggerCrumb, PickAction } from '../types.ts' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MenuState } from '../core/contract.ts' @@ -8,6 +8,8 @@ import type { MenuState } from '../core/contract.ts' export interface MenuViewInjected { /** The service's menu state store (read-only here; MenuView subscribes). */ menu: SnapshotStore + /** Crumbs published per source for the open menu; sources without a header never appear. */ + headers: SnapshotStore> /** * Pointer pick routed back through the service pipeline. * @param source - source (group) name. @@ -15,6 +17,19 @@ export interface MenuViewInjected { * @param action - settling pick (default) or the candidate's drill action. */ onPick: (source: string, index: number, action?: PickAction) => void + /** + * Pointer hover routed to the shared highlight (pointer and keyboard drive + * one highlight — last input wins). + * @param source - source (group) name. + * @param index - candidate index within the group. + */ + onHover: (source: string, index: number) => void + /** + * Pointer pick on one header crumb, routed back through the source's drill path. + * @param source - source (group) name. + * @param index - crumb index within that source's published header. + */ + onCrumb: (source: string, index: number) => void /** Dismiss the menu (external pointer outside the composer area). */ onDismiss: () => void } diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts index 685364f489..eab79f1066 100644 --- a/packages/client/ui-input-trigger/src/core/contract.ts +++ b/packages/client/ui-input-trigger/src/core/contract.ts @@ -51,6 +51,7 @@ export type MenuEvent = | { readonly type: 'source-settled'; readonly generation: number; readonly source: string; readonly items?: readonly InputTriggerCandidate[] } | { readonly type: 'source-failed'; readonly generation: number; readonly source: string } | { readonly type: 'move'; readonly dir: 1 | -1 } + | { readonly type: 'hover'; readonly source: string; readonly index: number } | { readonly type: 'close' } /** Pure menu reducer; returns the same reference when the event is stale or a no-op. */ diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts index 3fe40ee18c..47a6090d8f 100644 --- a/packages/client/ui-input-trigger/src/core/menu.ts +++ b/packages/client/ui-input-trigger/src/core/menu.ts @@ -81,7 +81,8 @@ const allReadyEmpty = (groups: MenuState['groups']): boolean => * open menu, or the roster is dropped; a settlement or failure leaving every * group ready-and-empty (or no groups) auto-closes; `source-failed` silently * removes the group (the shell logs); `move` cycles the highlight across - * ready items. + * ready items; `hover` parks it on one ready item (pointer and keyboard + * share the single highlight — last input wins). * * @param state - Current menu state. * @param ev - Menu event. @@ -131,6 +132,14 @@ export const menuReduce: MenuReduce = (state, ev) => { if (hl && next.source === hl.source && next.index === hl.index) return state return { ...state, highlight: next } } + case 'hover': { + if (!state.open) return state + const target = validHighlight({ source: ev.source, index: ev.index }, state.groups) + if (target === null) return state + const hl = state.highlight + if (hl && hl.source === target.source && hl.index === target.index) return state + return { ...state, highlight: target } + } case 'close': return closed(state) } diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1f8f3658e2..bd862a71a1 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -40,11 +40,14 @@ export type PickVia = 'menu' | 'space' | 'enter' /** What a pick asks for: resolve the candidate, or drill into it in place. */ export type PickAction = 'pick' | 'drill' +/** Leading glyph token of one menu candidate, mapped to its SVG by the menu view. */ +export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session' + /** One menu candidate. Pure display data — zero behavior declaration. */ export interface InputTriggerCandidate { readonly name: string readonly description?: string - readonly icon?: string + readonly icon?: InputTriggerCandidateIcon readonly hint?: string /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */ readonly section?: string @@ -58,6 +61,34 @@ export interface InputTriggerCandidate { readonly drill?: boolean } +/** + * One crumb of a source's menu header. The pipeline treats `value` as opaque + * and hands it straight back on pick, so a source names its own destinations. + */ +export interface InputTriggerCrumb { + /** Rendered text of this step. */ + readonly label: string + /** Opaque source-owned pick payload, returned through `onPick`. */ + readonly value: string + /** The step the menu is currently showing; rendered as the trailing, unclickable crumb. */ + readonly current?: boolean +} + +/** What a source needs to decide the header of the open menu. */ +export interface HeaderRequest { + /** Text between the trigger char and the caret, live-filtered. */ + readonly query: string + /** Whether the active @file token is an open quoted path. */ + readonly quoted?: boolean + /** + * True while this menu was opened or last re-scoped by a drill pick. It + * survives further typing and clears when the menu closes, so a query typed + * after a drill still reads as drilled. The pipeline owns the fact; what it + * means for a header is the source's to decide. + */ + readonly drilled: boolean +} + /** * Non-text composer submission state visible to enter adjudication. The * composer owns the actual attachment payloads; adjudication only needs their @@ -74,6 +105,8 @@ export interface CandidateRequest { /** Whether the active @file token is an open quoted path. */ readonly quoted?: boolean readonly position: TriggerPosition + /** Whether this menu was opened or last re-scoped by a drill pick; see {@link HeaderRequest.drilled}. */ + readonly drilled: boolean readonly signal: AbortSignal } @@ -122,6 +155,18 @@ export interface InputTriggerSource { /** Whether the menu renders the source-title row; defaults to true. */ readonly showGroupTitle?: boolean candidates(session: ClientSessionContext, req: CandidateRequest): Promise + /** + * Synchronous breadcrumb rendered above this source's group, re-polled on + * every hit. Implementing IS the participation claim; `undefined` means + * this request needs no header. A crumb pick routes back through + * {@link InputTriggerSource.onPick} with `action: 'drill'` and the crumb's + * `value` as the candidate value, so returning to a step and descending + * into one are the same outcome. + * @param session - stable session projection. + * @param req - the live query and how the menu reached it. + * @returns the crumbs to render, or undefined for no header. + */ + header?(session: ClientSessionContext, req: HeaderRequest): readonly InputTriggerCrumb[] | undefined /** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */ onPick(pick: InputTriggerPick): PickOutcome /** Synchronous space-time adjudication over hot state only. `token` is the just-completed leading token (e.g. '/goal'). */ diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 9cf41c62a1..6b9160c6bf 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -50,7 +50,7 @@ describe('apply', () => { const { ctx, locale } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() const t = locale.bind('slash.menu') - expect(t('command')).toBe('命令') + expect(t('command')).toBe('指令') locale.setLocale('en') expect(t('skill')).toBe('Skills') expect(t('subagent')).toBe('Subagents') @@ -81,9 +81,16 @@ describe('apply', () => { ctx.sessions.scope(sid('a'))!, ) expect(injected.menu).toBe(controller.menu) + expect(injected.headers).toBe(controller.headers) // The pick face routes into the controller pipeline (closed menu → no-op). injected.onPick('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) + // The crumb face routes into the controller too (closed menu → no-op). + injected.onCrumb('command', 0) + expect(controller.menu.getSnapshot().open).toBe(false) + // The hover face routes into the controller too (closed menu → no-op). + injected.onHover('command', 0) + expect(controller.menu.getSnapshot().open).toBe(false) // The dismiss face routes into the controller too (closed menu → no-op). injected.onDismiss() expect(controller.menu.getSnapshot().open).toBe(false) diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts index a849a7c69d..532f608fcf 100644 --- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts @@ -16,6 +16,14 @@ function open(sources: readonly string[], h: TriggerHit = hit()): MenuState { const item = (name: string) => ({ name }) +/** Two ready groups: command [goal, model], skill [commit]. */ +function ready(): MenuState { + let s = open(['command', 'skill']) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] }) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] }) + return s +} + describe('menuReduce hit', () => { it('opens a new generation with all groups pending', () => { const s = open(['command', 'skill']) @@ -147,14 +155,6 @@ describe('menuReduce source-failed', () => { }) describe('menuReduce move', () => { - /** Two ready groups: command [goal, model], skill [commit]. */ - function ready(): MenuState { - let s = open(['command', 'skill']) - s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] }) - s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] }) - return s - } - it('cycles forward across groups and wraps', () => { let s = ready() s = menuReduce(s, { type: 'move', dir: 1 }) @@ -195,6 +195,28 @@ describe('menuReduce move', () => { }) }) +describe('menuReduce hover', () => { + it('parks the highlight on the hovered ready item', () => { + const s = menuReduce(ready(), { type: 'hover', source: 'skill', index: 0 }) + expect(s.highlight).toEqual({ source: 'skill', index: 0 }) + }) + + it('is a no-op reference when closed, on invalid targets, and on the current highlight', () => { + const closed = menuReduce(ready(), { type: 'close' }) + expect(menuReduce(closed, { type: 'hover', source: 'command', index: 0 })).toBe(closed) + const s = ready() + expect(menuReduce(s, { type: 'hover', source: 'ghost', index: 0 })).toBe(s) + expect(menuReduce(s, { type: 'hover', source: 'command', index: 5 })).toBe(s) + expect(menuReduce(s, { type: 'hover', source: 'command', index: 0 })).toBe(s) + }) + + it('ignores a hover into a still-pending group', () => { + let s = open(['command', 'skill']) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] }) + expect(menuReduce(s, { type: 'hover', source: 'skill', index: 0 })).toBe(s) + }) +}) + describe('menuReduce close', () => { it('clears everything but keeps the generation for stale-drop', () => { let s = open(['command']) diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index c9bf518b08..b174cb8a35 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -2,10 +2,10 @@ /** * MenuView rendering spec, props-direct: closed store * renders null, groups render in roster order under localized title rows - * (unknown sources fall back to the raw name) with pending rows as loading, - * pointer picks route (source, index) back without stealing focus, the - * highlight is exposed through aria-activedescendant + aria-selected, and - * the list height clamps to the space above the composer. + * (unknown sources fall back to the raw name) with pending rows as skeleton + * placeholders, pointer picks route (source, index) back without stealing + * focus, the highlight is exposed through aria-activedescendant + + * aria-selected, and the list height clamps to the space above the composer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -13,7 +13,9 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locales.ts' -import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { + InputTriggerCrumb, MenuState, TriggerHit, +} from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { MenuView } from '../src/client/MenuView.tsx' const hit: TriggerHit = { @@ -32,7 +34,7 @@ function openState(partial?: Partial): MenuState { hit, generation: 1, groups: [ - { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] }, + { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: 'file' }, { name: 'plan' }] }, { source: 'skill', status: 'pending', items: [] }, ], highlight: { source: 'command', index: 0 }, @@ -57,12 +59,32 @@ afterEach(() => { // unknown source comes back verbatim (its raw name). const t = makeTranslate(zh, commonZh) -function mount(state: MenuState) { +function mount(state: MenuState, crumbs: ReadonlyMap = new Map()) { const menu = createSnapshotStore(state) + const headers = createSnapshotStore>(crumbs) const onPick = vi.fn() + const onCrumb = vi.fn() + const onHover = vi.fn() const onDismiss = vi.fn() - const view = render() - return { menu, onPick, onDismiss, view } + const view = render( + , + ) + return { menu, headers, onPick, onCrumb, onHover, onDismiss, view } +} + +/** The bounded menu shell: it owns the height clamp, the listbox scrolls inside it. */ +function menuShell(): HTMLElement { + const shell = document.querySelector('[data-trigger-menu]') + if (!(shell instanceof HTMLElement)) throw new Error('menu shell is not rendered') + return shell } /** The non-interactive group title rows (role=presentation), in document order. */ @@ -81,11 +103,15 @@ describe('MenuView', () => { expect(view.container.childElementCount).toBe(0) }) - it('renders ready groups as option rows and pending groups as loading rows', () => { + it('renders ready groups as option rows and pending groups as two skeleton rows', () => { mount(openState()) const options = screen.getAllByRole('option') - expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan']) - expect(screen.queryByText('正在加载…')).not.toBeNull() + expect(options.map(o => o.textContent)).toEqual(['goalSet up a goal', 'plan']) + // The icon token renders as an SVG glyph, not text. + expect(options[0]?.querySelector('svg')).not.toBeNull() + expect(options[1]?.querySelector('svg')).toBeNull() + const status = screen.getByRole('status', { name: '正在加载…' }) + expect(status.children).toHaveLength(2) }) it('keeps an opted-out source title hidden while its candidates are pending', () => { @@ -94,7 +120,7 @@ describe('MenuView', () => { highlight: null, })) expect(screen.queryByText('reference')).toBeNull() - expect(screen.getByText('正在加载…')).toBeTruthy() + expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy() }) it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => { @@ -106,7 +132,7 @@ describe('MenuView', () => { { source: 'skill', status: 'pending', items: [] }, ], })) - expect(titles(view.container)).toEqual(['命令', 'mystery', '技能']) + expect(titles(view.container)).toEqual(['指令', 'mystery', '技能']) }) it('renders contiguous candidate sections once without changing option indexes', () => { @@ -117,14 +143,14 @@ describe('MenuView', () => { items: [ { name: 'Folder · src/', section: '文件与文件夹' }, { name: 'File · README.md', section: '文件与文件夹' }, - { name: 'Session · Research', section: 'Session 对话' }, + { name: 'Session · Research', section: '对话' }, ], }], highlight: { source: 'reference', index: 0 }, })) expect(screen.queryByText('reference')).toBeNull() expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) - expect(screen.getAllByText('Session 对话')).toHaveLength(1) + expect(screen.getAllByText('对话')).toHaveLength(1) const options = screen.getAllByRole('option') expect(options.map(option => option.textContent)).toEqual([ 'Folder · src/', @@ -183,23 +209,23 @@ describe('MenuView', () => { it('caps the list height at the design maximum when the composer sits low enough', () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) mount(openState()) - expect(screen.getByRole('listbox').style.maxHeight).toBe('320px') + expect(menuShell().style.maxHeight).toBe('320px') }) it('clamps the list height to the space above the composer minus the safe margin', () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect) mount(openState()) - expect(screen.getByRole('listbox').style.maxHeight).toBe('188px') + expect(menuShell().style.maxHeight).toBe('188px') }) it('re-fits the height when the window resizes', () => { const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect') rect.mockReturnValue({ bottom: 800 } as DOMRect) mount(openState()) - expect(screen.getByRole('listbox').style.maxHeight).toBe('320px') + expect(menuShell().style.maxHeight).toBe('320px') rect.mockReturnValue({ bottom: 100 } as DOMRect) act(() => { window.dispatchEvent(new Event('resize')) }) - expect(screen.getByRole('listbox').style.maxHeight).toBe('88px') + expect(menuShell().style.maxHeight).toBe('88px') }) it('pointerdown outside the menu (no composer card ancestor) dismisses', () => { @@ -219,7 +245,15 @@ describe('MenuView', () => { const onDismiss = vi.fn() render(
- + >(new Map())} + onPick={vi.fn()} + onCrumb={vi.fn()} + onHover={vi.fn()} + onDismiss={onDismiss} + t={t} + />
, ) @@ -252,4 +286,47 @@ describe('MenuView', () => { expect(notPrevented).toBe(false) expect(onPick).toHaveBeenCalledWith('command', 1) }) + + it('pointer motion over a row routes hover; the highlighted row stays silent', () => { + const { onHover } = mount(openState()) + const options = screen.getAllByRole('option') + fireEvent.mouseMove(options[1]!) + expect(onHover).toHaveBeenCalledWith('command', 1) + onHover.mockClear() + // Index 0 already holds the highlight: no hover round-trip. + fireEvent.mouseMove(options[0]!) + expect(onHover).not.toHaveBeenCalled() + }) + + it('renders a source header as a breadcrumb above the list, current step last', () => { + mount(openState(), new Map([['command', [ + { label: 'Workspace', value: 'root' }, + { label: 'src', value: 'src' }, + { label: 'module1', value: 'module1', current: true }, + ]]])) + const nav = screen.getByRole('navigation', { name: '目录导航' }) + expect([...nav.querySelectorAll('button')].map(button => button.textContent)) + .toEqual(['Workspace', 'src', 'module1']) + // The listbox holds options alone; the header is its sibling, not a row. + expect(screen.getByRole('listbox').contains(nav)).toBe(false) + }) + + it('mousedown on a crumb routes (source, index) without stealing focus; the current step is inert', () => { + const { onCrumb } = mount(openState(), new Map([['command', [ + { label: 'Workspace', value: 'root' }, + { label: 'src', value: 'src', current: true }, + ]]])) + const crumbs = screen.getByRole('navigation', { name: '目录导航' }).querySelectorAll('button') + expect(fireEvent.mouseDown(crumbs[0]!)).toBe(false) + expect(onCrumb).toHaveBeenCalledWith('command', 0) + onCrumb.mockClear() + expect(crumbs[1]!.disabled).toBe(true) + fireEvent.mouseDown(crumbs[1]!) + expect(onCrumb).not.toHaveBeenCalled() + }) + + it('renders no header for a source that published no crumbs', () => { + mount(openState()) + expect(screen.queryByRole('navigation')).toBeNull() + }) }) diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index b494abf478..e63a736694 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -572,6 +572,127 @@ describe('pick / scoped input events', () => { }) }) +describe('header / drilled descent', () => { + /** A source that publishes one crumb per path segment of a drilled query. */ + function crumbSource() { + const requests: Array<{ query: string; quoted?: boolean; drilled: boolean }> = [] + const picks: InputTriggerPick[] = [] + const source: InputTriggerSource = { + trigger: '@', + name: 'reference', + candidates: () => Promise.resolve([{ name: 'src', drill: true, value: 'src' }]), + header: (_session, req) => { + requests.push({ ...req }) + if (!req.drilled || !req.query.includes('/')) return undefined + return req.query.split('/').filter(Boolean).map(label => ({ label, value: label })) + }, + onPick: (pick) => { + picks.push(pick) + return pick.action === 'drill' ? { text: `@${String(pick.candidate.value)}/`, continue: true } : undefined + }, + } + return { source, requests, picks } + } + + it('publishes no crumbs for a typed path and asks every source how the menu was reached', async () => { + const { source, requests } = crumbSource() + const { controller } = controllerBench([source]) + controller.track('@src/', 5, { tier: 'plain' }, 1) + await tick() + expect(requests).toEqual([{ query: 'src/', drilled: false, quoted: false }]) + expect(controller.headers.getSnapshot().size).toBe(0) + }) + + it('publishes crumbs once a drill produced the query, and drops them when the menu closes', async () => { + const { source, picks } = crumbSource() + const { controller, actx } = controllerBench([source]) + const texts: string[] = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req.text) + return true + }) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + expect(texts).toEqual(['@src/']) + expect(picks[0]?.action).toBe('drill') + // The drilled text lands as the next tracked draft. + controller.track('@src/', 5, { tier: 'plain' }, 2) + await tick() + expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }]) + controller.dismiss() + expect(controller.headers.getSnapshot().size).toBe(0) + }) + + it('routes a crumb through the source drill path and refuses the current step', async () => { + const { source, picks } = crumbSource() + const { controller, actx } = controllerBench([source]) + actx.on('slash/input-insert-text', () => true) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + controller.track('@src/lib/', 9, { tier: 'plain' }, 2) + await tick() + const trail = controller.headers.getSnapshot().get('reference') + expect(trail?.map(crumb => crumb.label)).toEqual(['src', 'lib']) + picks.length = 0 + controller.pickCrumb('reference', 0) + expect(picks).toHaveLength(1) + expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' }) + }) + + it('publishes no crumbs when the input refused the drill edit', async () => { + const { source } = crumbSource() + const { controller } = controllerBench([source]) + // No listener accepts the insert, so the descent text never landed. + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + controller.track('@src/', 5, { tier: 'plain' }, 2) + await tick() + expect(controller.headers.getSnapshot().size).toBe(0) + }) + + it('drops a source whose header throws and keeps the rest of the menu', async () => { + const failing: InputTriggerSource = { + trigger: '@', + name: 'broken', + candidates: () => Promise.resolve([{ name: 'x' }]), + header: () => { throw new Error('header boom') }, + onPick: () => undefined, + } + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { controller } = controllerBench([failing]) + controller.track('@x', 2, { tier: 'plain' }, 1) + await tick() + expect(controller.headers.getSnapshot().size).toBe(0) + expect(controller.menu.getSnapshot().open).toBe(true) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('tells candidate fetches how the menu was reached', async () => { + const seen: boolean[] = [] + const source: InputTriggerSource = { + trigger: '@', + name: 'reference', + candidates: (_session, req) => { + seen.push(req.drilled) + return Promise.resolve([{ name: 'src', drill: true, value: 'src' }]) + }, + onPick: pick => (pick.action === 'drill' ? { text: '@src/', continue: true } : undefined), + } + const { controller, actx } = controllerBench([source]) + actx.on('slash/input-insert-text', () => true) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + controller.track('@src/', 5, { tier: 'plain' }, 2) + await tick() + expect(seen).toEqual([false, true]) + }) +}) + describe('lexicon', () => { function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource { return { @@ -700,6 +821,18 @@ describe('arbitrate', () => { expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) }) + it('hover parks the shared highlight; disposed controllers ignore it', async () => { + const { controller } = await menuBench() + controller.hover('command', 1) + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 }) + // Keyboard keeps moving from the parked spot: last input wins. + expect(controller.arbitrate('up', false)).toBe('consumed') + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + controller.dispose() + controller.hover('command', 1) + expect(controller.menu.getSnapshot().highlight).toBeNull() + }) + it('enter picks the highlight through the pipeline', async () => { const { controller, cmd } = await menuBench() expect(controller.arbitrate('enter', false)).toBe('pick-highlighted') diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index a195c70e08..e944070fe6 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index d07ae0d563..ea634dbafa 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 19df358147..51ca031762 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 0d54a69f01..e368cfb6a9 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes" @@ -49,7 +48,6 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -64,7 +62,6 @@ "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index b0ec866a9a..5bfbcb2d1e 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,9 +1,6 @@ /** One Host-generation model catalog shared by every Session selector. */ -import { - type IApiClient, - type ModelCatalog, -} from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -25,8 +22,8 @@ export class ModelCatalogDirectory { private generation = 0 private inflight: Promise | undefined - /** @param api - shared connection API client. */ - constructor(private readonly api: IApiClient) {} + /** @param session - Session Remote namespace carrying the Host-generation catalog. */ + constructor(private readonly session: Pick) {} /** * Return the current generation's catalog, sharing its one in-flight load. @@ -41,14 +38,14 @@ export class ModelCatalogDirectory { draft.status = 'loading' draft.error = null }) - const operation = this.api.llm.models({}).then((response) => { - if (!response.result.ok) { - throw new Error(`${response.result.error.code}: ${response.result.error.message}`) + const operation = this.session.modelCatalog().then((response) => { + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) } if (generation === this.generation) { - this.store.set({ value: response.result.value, status: 'ready', error: null }) + this.store.set({ value: response.value, status: 'ready', error: null }) } - return response.result.value + return response.value }).catch((error: unknown) => { if (generation === this.generation) { this.store.update((draft) => { diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index d68e0463d3..dd7d4a89fd 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -2,7 +2,7 @@ * Model selection plugin, browser half — TWO entries over ONE per-session * directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect * contribution and the composer's named `conversation.input.model` seat share - * one Host-generation `llm.models` catalog, combine it with the Session's + * one Host-generation `session/modelCatalog` catalog, combine it with the Session's * durable model-selection projection, and submit through `session.selectModel`. * A switch made in either entry is what the other shows next. Failures * ride each entry's own retry surface (popup shell error/retry; seat menu diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index 7e01ecd5a2..2053d70b85 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -15,7 +15,6 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ModelCatalogDirectory } from './catalog.ts' import { ModelDirectory } from './directory.ts' @@ -34,7 +33,7 @@ interface LiveState { /** The `ctx.modelDirectories` session model-selection service. */ export class ModelDirectoryResolver extends Service { - static inject = ['sessions', 'remote', 'remote.session', 'connection'] + static inject = ['sessions', 'remote', 'remote.session'] private readonly live: LiveState = { directories: new Map() } private readonly catalog: ModelCatalogDirectory @@ -49,9 +48,7 @@ export class ModelDirectoryResolver extends Service { constructor(ctx: Context, config: { blockReason: () => string }) { super(ctx, 'modelDirectories') this.blockReason = config.blockReason - const connection = ctx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error('ui-model-selection: connection service is unavailable') - this.catalog = new ModelCatalogDirectory(connection.api) + this.catalog = new ModelCatalogDirectory(ctx.remote.session) void this.catalog.load().catch(() => { /* selectors expose the shared error */ }) ctx.on('connection/reset', () => { this.catalog.resetGeneration() diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 459c972208..1bf11733b5 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -65,6 +65,18 @@ async function bench() { // block follows this, never catalog membership. let routable = true const sessionRemote = { + modelCatalog: () => { + calls.models += 1 + return Promise.resolve({ + ok: true as const, + value: { + default: defaultSelection, + routableProviders: routable ? ['deepseek-official'] : [], + groups: GROUPS, + failures: [], + }, + }) + }, selectModel: (payload: { sessionId: SessionId; provider: string; model: string; reasoningEffort?: string }) => { calls.select += 1 selected = { @@ -80,28 +92,6 @@ async function bench() { } const remote = Object.assign(new TestRemote(ctx), { session: sessionRemote }) ctx.reflect.provide('remote.session', sessionRemote) - ctx.provide('connection', { - api: { - llm: { - models: () => { - calls.models += 1 - return Promise.resolve({ - rpcId: 'model-catalog', - result: { - ok: true as const, - value: { - default: defaultSelection, - routableProviders: routable ? ['deepseek-official'] : [], - groups: GROUPS, - failures: [], - }, - }, - }) - }, - }, - }, - isLoopback: false, - } as never) const blocks = new Map() ctx.provide('conversation', { blocks: { diff --git a/packages/client/ui-model-selection/tests/catalog.client.spec.ts b/packages/client/ui-model-selection/tests/catalog.client.spec.ts index 95f3d440b4..a9333113e8 100644 --- a/packages/client/ui-model-selection/tests/catalog.client.spec.ts +++ b/packages/client/ui-model-selection/tests/catalog.client.spec.ts @@ -1,4 +1,4 @@ -import type { IApiClient, ModelCatalog } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' import { describe, expect, it, vi } from 'vitest' import { ModelCatalogDirectory } from '../src/client/catalog.ts' @@ -10,16 +10,16 @@ const catalog = (model: string): ModelCatalog => ({ }) function directory(models: () => Promise): ModelCatalogDirectory { - return new ModelCatalogDirectory({ llm: { models } } as unknown as IApiClient) + return new ModelCatalogDirectory({ modelCatalog: models } as unknown as ClientRemote['session']) } describe('ModelCatalogDirectory', () => { it('shares one failing request, exposes the RPC error, and permits a retry', async () => { const models = vi.fn() .mockResolvedValueOnce({ - result: { ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} } }, + ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} }, }) - .mockResolvedValueOnce({ result: { ok: true, value: catalog('recovered') } }) + .mockResolvedValueOnce({ ok: true, value: catalog('recovered') }) const subject = directory(models) const first = subject.load() @@ -40,10 +40,10 @@ describe('ModelCatalogDirectory', () => { const stale = subject.load() subject.resetGeneration() - first.resolve({ result: { ok: true, value: catalog('stale') } }) + first.resolve({ ok: true, value: catalog('stale') }) await expect(stale).resolves.toEqual(catalog('stale')) expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading' }) - second.resolve({ result: { ok: true, value: catalog('fresh') } }) + second.resolve({ ok: true, value: catalog('fresh') }) await vi.waitFor(() => { expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' }) }) @@ -62,7 +62,7 @@ describe('ModelCatalogDirectory', () => { first.reject(new Error('stale failure')) await expect(stale).rejects.toThrow('stale failure') expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading', error: null }) - second.resolve({ result: { ok: true, value: catalog('fresh') } }) + second.resolve({ ok: true, value: catalog('fresh') }) await vi.waitFor(() => { expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' }) }) @@ -70,7 +70,7 @@ describe('ModelCatalogDirectory', () => { it('contains refresh failures while retaining old data and clears it on a failed Host reset', async () => { const models = vi.fn() - .mockResolvedValueOnce({ result: { ok: true, value: catalog('old') } }) + .mockResolvedValueOnce({ ok: true, value: catalog('old') }) .mockRejectedValueOnce('refresh failed') .mockRejectedValueOnce(new Error('reset failed')) const subject = directory(models) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 037c6d15bd..3531416e93 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 8c3f87f136..97cd4f4ca4 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 9410edae05..8041b7dbf3 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d4ba27bacc..8415fcf059 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -32,6 +32,8 @@ export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' +export { relativeTime } from './relative-time.ts' +export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' diff --git a/packages/client/ui-primitives/src/relative-time.ts b/packages/client/ui-primitives/src/relative-time.ts new file mode 100644 index 0000000000..f1d7f7e37e --- /dev/null +++ b/packages/client/ui-primitives/src/relative-time.ts @@ -0,0 +1,36 @@ +/** + * Compact relative-time bucketing shared by every surface that dates a + * session. Bucketing is here so two surfaces naming the same session agree; + * the words stay in each plugin's own dictionary, per locale-owned copy. + * + * @module @deepseek-ai/dsh-client-ui-primitives/relative-time + */ + +/** Relative-time bucket of a dated row's trailing label. */ +export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years' + +/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */ +export interface RelativeTime { + unit: RelativeTimeUnit + n: number +} + +/** + * Compact relative time, as a structured bucket the renderer localizes + * ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en). + * @param at - epoch ms of the dated moment. + * @param now - current epoch ms (injected for pure rendering). + * @returns the row's trailing time bucket and magnitude. + */ +export function relativeTime(at: number, now: number): RelativeTime { + const MIN = 60_000 + const HOUR = 3_600_000 + const DAY = 86_400_000 + const diff = Math.max(0, now - at) + if (diff < MIN) return { unit: 'now', n: 0 } + if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) } + if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) } + if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) } + if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) } + return { unit: 'years', n: Math.floor(diff / (365 * DAY)) } +} diff --git a/packages/client/ui-primitives/tests/relative-time.client.spec.ts b/packages/client/ui-primitives/tests/relative-time.client.spec.ts new file mode 100644 index 0000000000..2d482dd693 --- /dev/null +++ b/packages/client/ui-primitives/tests/relative-time.client.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' + +const MIN = 60_000 +const HOUR = 3_600_000 +const DAY = 86_400_000 +const now = 1_800_000_000_000 + +describe('relativeTime', () => { + it('buckets each distance and reports its magnitude', () => { + expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 }) + expect(relativeTime(now - 5 * MIN, now)).toEqual({ unit: 'minutes', n: 5 }) + expect(relativeTime(now - 3 * HOUR, now)).toEqual({ unit: 'hours', n: 3 }) + expect(relativeTime(now - 2 * DAY, now)).toEqual({ unit: 'days', n: 2 }) + expect(relativeTime(now - 60 * DAY, now)).toEqual({ unit: 'months', n: 2 }) + expect(relativeTime(now - 400 * DAY, now)).toEqual({ unit: 'years', n: 1 }) + }) + + it('reports the coarser bucket at each boundary', () => { + expect(relativeTime(now - MIN, now)).toEqual({ unit: 'minutes', n: 1 }) + expect(relativeTime(now - HOUR, now)).toEqual({ unit: 'hours', n: 1 }) + expect(relativeTime(now - DAY, now)).toEqual({ unit: 'days', n: 1 }) + expect(relativeTime(now - 30 * DAY, now)).toEqual({ unit: 'months', n: 1 }) + expect(relativeTime(now - 365 * DAY, now)).toEqual({ unit: 'years', n: 1 }) + }) + + it('reads a future moment as the present rather than a negative distance', () => { + expect(relativeTime(now + DAY, now)).toEqual({ unit: 'now', n: 0 }) + }) +}) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 5b4307038e..fae26277f7 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/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 packages/client/ui-reference/README.md -README.md: 7980a68632b8dba45e5a1b9d13b7d4380bec6c22 -README.zh.md: bd9aad7855cc6fd1bbf9bae05cd0521bafaab7fd +README.md: 1f82f4bb30f2ad5d194a70279f9e80885d6a048e +README.zh.md: 03a79ec4ff34431a63d8a7b0de115e3ec63b9392 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 7980a68632..1f82f4bb30 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-reference` is the unified Web `@file` and `@session` reference source: it registers the `reference` entry in the composer's inline-suggestion machinery so a user typing `@` sees file and session candidates in one list. Files order before sessions, sections are labelled with locale-registered terms, and either candidate domain can fail independently without blocking the other. A pick inserts an atomic inline reference — file, folder, and session alike — whose hidden serialized and clipboard form is the natural text the shared `@path` grammar defines; a directory row additionally carries a drill verb (Tab or the row's chevron) that keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Selecting a session routes through the session-reference service, which validates the mention and captures model context at the pre-step boundary; this package itself registers no prompt or tool. +`dsh-client-ui-reference` is the unified Web `@file` and `@session` reference source: it registers the `reference` entry in the composer's inline-suggestion machinery so a user typing `@` sees file and session candidates in one list. Files order before sessions, sections are labelled with locale-registered terms, and either candidate domain can fail independently without blocking the other. Each row carries only what distinguishes it: a file names its parent directory and nothing at the workspace root, a session names its workspace only when that workspace is not the current one, and a drilled directory listing names none because its breadcrumb already does. A pick inserts an atomic inline reference — file, folder, and session alike — whose hidden serialized and clipboard form is the natural text the shared `@path` grammar defines; a directory row additionally carries a drill verb (Tab or the row's chevron) that keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Selecting a session routes through the session-reference service, which validates the mention and captures model context at the pre-step boundary; this package itself registers no prompt or tool. ## Table of Contents @@ -49,7 +49,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c ### Candidate flow -For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. +For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. ### Serialization diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index bd9aad7855..03a79ec4ff 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-reference` 是统一的 Web `@file` 与 `@session` 引用 source:它把 `reference` 条目注册进编辑器的行内建议机制,让用户在输入 `@` 时于同一个列表中看到文件与会话候选。文件排在会话之前,分组标题使用注册在 locale 字典中的标签,任一候选领域失败都会独立降级、不阻塞另一领域。选择一项会插入原子行内引用——文件、文件夹与会话皆然——其隐藏的序列化与剪贴板形式就是共享 `@path` 语法所定义的自然文本;目录行额外携带一个钻取动词(Tab 或行尾 chevron),保持可编辑的路径纯文本并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。选择会话会经 session-reference 服务路由,该服务校验 mention 并在 pre-step 边界捕获模型上下文;本包自身不注册任何提示词或工具。 +`dsh-client-ui-reference` 是统一的 Web `@file` 与 `@session` 引用 source:它把 `reference` 条目注册进编辑器的行内建议机制,让用户在输入 `@` 时于同一个列表中看到文件与会话候选。文件排在会话之前,分组标题使用注册在 locale 字典中的标签,任一候选领域失败都会独立降级、不阻塞另一领域。每一行只承载能区分它的信息:文件显示其父目录、位于工作区根目录时不显示;会话仅在其工作区不是当前工作区时显示该工作区;下钻后的目录列表不显示位置,因为面包屑已经承载了它。选择一项会插入原子行内引用——文件、文件夹与会话皆然——其隐藏的序列化与剪贴板形式就是共享 `@path` 语法所定义的自然文本;目录行额外携带一个钻取动词(Tab 或行尾 chevron),保持可编辑的路径纯文本并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。选择会话会经 session-reference 服务路由,该服务校验 mention 并在 pre-step 边界捕获模型上下文;本包自身不注册任何提示词或工具。 ## 目录 @@ -49,7 +49,7 @@ kind: "package-reference" ### 候选流程 -对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。 +对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 ### 序列化 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 340aa2bceb..70928a652c 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -33,6 +33,8 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" ], @@ -46,23 +48,30 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "files": [ diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 7b87af0e1f..09ecf5aaa9 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -3,6 +3,12 @@ * the cancellable generated Remote namespaces in parallel with deterministic * ordering and labels. * + * Rows carry only what distinguishes them: a file names its parent directory + * (nothing at the workspace root), a directory listing names none because its + * breadcrumb already does, and a session names its workspace only when that + * workspace is not the current one. A session is dated from the Host session + * list, so the `@` menu and the session list never disagree about its age. + * * @module @deepseek-ai/dsh-client-ui-reference/client */ // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. @@ -10,17 +16,22 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' import type { - ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, + ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import { en, NS, zh, type ReferenceKey } from './locales.ts' /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ - 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', + 'remote.sessionReferenceResolver', ] /** @@ -30,34 +41,52 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) + const connection = ctx.get('connection') as ConnectionHandle + const sessions = ctx.get('sessions') as ISessions const source: InputTriggerSource = { trigger: '@', name: 'reference', showGroupTitle: false, - async candidates(session: ClientSessionContext, { query, quoted, signal }) { - const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( + async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) { + const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) - const sessions = quoted === true + const sessionLookup = quoted === true ? Promise.resolve([] as SessionReferenceMentionCandidate[]) : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) - const [fileItems, sessionItems] = await Promise.all([files, sessions]) + const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup]) if (signal.aborted) return [] + // The header already names the directory being listed; rows repeat it only + // when there is no header to carry it. + const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined + const now = Date.now() + const home = connection.generation.getSnapshot()?.host.home + const listed = sessions.list.getSnapshot().byId return [ - ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, t)), - ...sessionItems.map(candidate => sessionCandidate(candidate, t)), + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), + ...sessionItems.map(candidate => sessionCandidate( + candidate, + listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt, + now, + home, + t, + )), ] }, + header(_session: ClientSessionContext, req) { + return crumbsFor(req.query, req.quoted === true, req.drilled, t) + }, onPick({ candidate, action }) { const value = parseCandidate(candidate.value) if (value?.kind === 'file') { // A directory row carries two verbs: the settling pick resolves the // folder itself as an atomic reference, while the drill action (Tab / - // row chevron) keeps the literal descent text and the open menu. + // row chevron / a header crumb) keeps the literal descent text and + // the open menu. if (value.fileKind === 'directory' && action === 'drill') { return { text: value.mention, continue: true } } @@ -93,16 +122,71 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => inputTriggers.registerSource(source), 'ui-reference: @ source') } -type Translate = (key: ReferenceKey) => string +type Translate = (key: ReferenceKey, params?: Record) => string type ReferenceCandidateValue = | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; label: string; mention: string } | { kind: 'session'; label: string; mention: string } -function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) { +/** + * The breadcrumb of a drilled directory listing, from the workspace root down + * to the directory being listed. + * + * Only a drill produces one: a path the user typed carries its own context in + * the draft, while a drill replaced the text they were reading with a deeper + * one and owes them the way back. + * @param query - the live query, path text following `@` or `@"`. + * @param quoted - whether the active token is an open quoted path. + * @param drilled - whether a drill pick, rather than typing, produced the query. + * @param t - the reference dictionary. + * @returns the crumbs, or undefined when this listing needs no header. + */ +function crumbsFor( + query: string, + quoted: boolean, + drilled: boolean, + t: Translate, +): readonly InputTriggerCrumb[] | undefined { + if (!drilled) return undefined + const slash = query.lastIndexOf('/') + if (slash < 0) return undefined + const segments = query.slice(0, slash).split('/').filter(segment => segment !== '') + const crumbs: InputTriggerCrumb[] = [{ + label: t('crumb.root'), + value: directoryValue(t('crumb.root'), quoted ? '@"' : '@'), + }] + for (const [index, segment] of segments.entries()) { + const path = segments.slice(0, index + 1).join('/') + const mention = formatFileMention({ path, kind: 'directory' }, quoted) + // A trail whose steps cannot all be written back as mention text would + // send the user somewhere they did not click; show no header instead. + if (mention === undefined) return undefined + crumbs.push({ + label: segment, + value: directoryValue(segment, mention), + ...(index === segments.length - 1 ? { current: true } : {}), + }) + } + return crumbs +} + +/** Project one directory destination as the drill payload `onPick` already understands. */ +function directoryValue(label: string, mention: string): string { + const value: ReferenceCandidateValue = { kind: 'file', fileKind: 'directory', label, mention } + return JSON.stringify(value) +} + +function fileCandidate( + candidate: FileReferenceCandidate, + preserveQuote: boolean, + withLocation: boolean, + t: Translate, +) { const mention = formatFileMention(candidate, preserveQuote) if (mention === undefined) return [] - const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const slash = candidate.path.lastIndexOf('/') + const name = candidate.path.slice(slash + 1) + const parent = slash < 0 ? '' : candidate.path.slice(0, slash) const directory = candidate.kind === 'directory' const value: ReferenceCandidateValue = { kind: 'file', @@ -111,25 +195,40 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean mention, } return [{ - name: `${t(directory ? 'candidate.folder' : 'candidate.file')} · ${name}${directory ? '/' : ''}`, - description: candidate.path, + name: `${name}${directory ? '/' : ''}`, + // The location is the parent alone: repeating the name the row already + // shows says nothing, and a workspace-root entry has no parent to name. + ...(withLocation && parent !== '' ? { description: parent } : {}), + icon: directory ? 'folder' as const : 'file' as const, section: t('section.files'), value: JSON.stringify(value), ...(directory ? { drill: true } : {}), }] } -function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Translate) { - const location = candidate.cwd ?? t('candidate.noCwd') - const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` +function sessionCandidate( + candidate: SessionReferenceMentionCandidate, + updatedAt: number, + now: number, + home: string | undefined, + t: Translate, +) { + const { unit, n } = relativeTime(updatedAt, now) + const age = unit === 'now' ? t('time.now') : t(`time.${unit}`, { n }) + // Candidates are ranked by workspace affinity, so the location only tells + // the user something when it is not the workspace they are already in. + const location = candidate.sameWorkspace + ? undefined + : candidate.cwd === undefined ? t('candidate.noCwd') : abbreviateHomePath(candidate.cwd, home) const value: ReferenceCandidateValue = { kind: 'session', label: candidate.label, mention: candidate.mention, } return { - name: `${t('candidate.session')} · ${candidate.label}`, - description, + name: candidate.label, + description: location === undefined ? age : `${location} · ${age}`, + icon: 'session' as const, section: t('section.sessions'), value: JSON.stringify(value), } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index 41ec525e75..ed5f67a61d 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -5,14 +5,24 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots' /** Dictionary namespace owned by this plugin. */ export const NS = 'reference' -/** Simplified Chinese dictionary (the key-set source of truth). */ +/** + * Simplified Chinese dictionary (the key-set source of truth). + * + * The `time.*` bucket words are this namespace's own copy of the session-row + * vocabulary: locale-owned copy keeps the words per plugin, while the + * bucketing they name is the one shared {@link relativeTime} in ui-primitives. + */ export const zh = { 'section.files': '文件与文件夹', - 'section.sessions': 'Session 对话', - 'candidate.file': '文件', - 'candidate.folder': '文件夹', - 'candidate.session': 'Session', + 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', + 'crumb.root': '工作区', + 'time.now': '刚刚', + 'time.minutes': '{n}分钟', + 'time.hours': '{n}小时', + 'time.days': '{n}天', + 'time.months': '{n}个月', + 'time.years': '{n}年', } satisfies Record /** The reference namespace key union. */ @@ -28,9 +38,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English dictionary, checked complete against the zh key set. */ export const en = { 'section.files': 'Files & folders', - 'section.sessions': 'Session conversations', - 'candidate.file': 'File', - 'candidate.folder': 'Folder', - 'candidate.session': 'Session', + 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', + 'crumb.root': 'Workspace', + 'time.now': 'now', + 'time.minutes': '{n}min', + 'time.hours': '{n}h', + 'time.days': '{n}d', + 'time.months': '{n}mo', + 'time.years': '{n}y', } satisfies Record diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 5b782521e0..4565e170db 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -4,7 +4,7 @@ * round-trip, and registration lifecycle. */ import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { @@ -17,6 +17,19 @@ import { apply as nodeApply } from '../src/index.ts' const sid = (value: string): SessionId => value as SessionId const session: ClientSessionContext = { sessionId: sid('target') } +/** The target session's own workspace: candidates in it are the `sameWorkspace` rows. */ +const HOME = '/Users/dev' +const CREATED_AT = 1_700_000_000_000 +/** Three days after every fixture's createdAt, so age copy is one fixed bucket. */ +const NOW = CREATED_AT + 3 * 86_400_000 +/** The Host session list dates a row; only a session missing from it falls back to createdAt. */ +const UPDATED_AT = NOW - 3_600_000 + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) +afterEach(() => { vi.useRealTimers() }) type RemoteEnvelope = | { ok: true; value: T } @@ -36,6 +49,7 @@ function request( query, quoted: options.quoted ?? false, position: 'inline', + drilled: false, signal: options.signal ?? new AbortController().signal, } } @@ -53,11 +67,13 @@ async function bench( value: [{ sessionId: sid('source'), label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, + cwd: `${HOME}/project`, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })), + listed: Record = {}, ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() let source: InputTriggerSource | undefined @@ -76,6 +92,8 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } }) + ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() if (source === undefined) throw new Error('reference source was not registered') @@ -85,7 +103,8 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ - 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', + 'remote.sessionReferenceResolver', ]) const { fiber } = await bench() let registered: InputTriggerSource | undefined @@ -105,6 +124,8 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.provide('connection', { generation: { getSnapshot: () => undefined } }) + ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false }) @@ -142,6 +163,7 @@ describe('candidates', () => { sessionId: SessionId label: string cwd: string + sameWorkspace: boolean createdAt: number mention: string }[] @@ -152,34 +174,39 @@ describe('candidates', () => { value: [{ sessionId: sid('source'), label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, + cwd: `${HOME}/project`, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[Research](dsh-session:InNvdXJjZSI)', }], }) } })) - const { source } = await bench(files, sessions) + const { source } = await bench(files, sessions, { source: { updatedAt: UPDATED_AT } }) const pending = source.candidates(session, request('re')) expect(files).toHaveBeenCalledTimes(1) expect(sessions).toHaveBeenCalledTimes(1) releaseSessions() releaseFiles() await expect(pending).resolves.toEqual([ + { + name: 'src/', + icon: 'folder', + section: 'Files & folders', + value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'src', mention: '@src/' }), + drill: true, + }, expect.objectContaining({ - name: 'Folder · src/', - description: 'src', + name: 'a b.md', + description: 'docs', + icon: 'file', section: 'Files & folders', }), expect.objectContaining({ - name: 'File · a b.md', - description: 'docs/a b.md', - section: 'Files & folders', - }), - expect.objectContaining({ - name: 'Session · Research', - description: 'source · /project · 2023-11-14T22:13:20.000Z', - section: 'Session conversations', + name: 'Research', + description: '~/project · 1h', + icon: 'session', + section: 'Sessions', }), ]) }) @@ -196,14 +223,15 @@ describe('candidates', () => { value: [{ sessionId: sid('source'), label: 'Research', - cwd: '/project', - createdAt: 0, + cwd: `${HOME}/project`, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })) const { source } = await bench(files, sessions) const quoted = await source.candidates(session, request('READ', { quoted: true })) - expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(quoted).toEqual([expect.objectContaining({ name: 'README.md', icon: 'file' })]) expect(source.onPick({ candidate: quoted[0]!, session, @@ -222,7 +250,7 @@ describe('candidates', () => { }) expect(sessions).not.toHaveBeenCalled() await expect(source.candidates(session, request('research'))).resolves.toEqual([ - expect.objectContaining({ name: 'Session · Research' }), + expect.objectContaining({ name: 'Research', icon: 'session' }), ]) }) @@ -255,25 +283,156 @@ describe('candidates', () => { await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) }) - it('omits redundant session ids and labels sessions without a cwd', async () => { + it('labels a session without a cwd and still dates it', async () => { const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [{ sessionId: sid('same'), label: 'same', - createdAt: 0, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[same](dsh-session:InNhbWUi)', }], })) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('same'))).resolves.toEqual([ expect.objectContaining({ - name: 'Session · same', - description: '(no cwd) · 1970-01-01T00:00:00.000Z', + name: 'same', + description: '(no cwd) · 3d', }), ]) }) + + it('falls back to the candidate createdAt for a session the Host list does not carry', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('unlisted'), + label: 'Unlisted run', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Unlisted run](dsh-session:InVubGlzdGVkIg)', + }], + })) + // A row absent from the list has no durable activity time to read. + const { source } = await bench(files, sessions, { other: { updatedAt: UPDATED_AT } }) + await expect(source.candidates(session, request('unlisted'))).resolves.toEqual([ + expect.objectContaining({ name: 'Unlisted run', description: '3d' }), + ]) + }) + + it('reads a session opened moments ago as the present, not a zero distance', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('just-now'), + label: 'Just now', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: NOW - 1_000, + mention: '@[Just now](dsh-session:Imp1c3Qtbm93Ig)', + }], + })) + const { source } = await bench(files, sessions, { 'just-now': { updatedAt: NOW - 1_000 } }) + await expect(source.candidates(session, request('just'))).resolves.toEqual([ + expect.objectContaining({ name: 'Just now', description: 'now' }), + ]) + }) + + it('dates a session in the current workspace without repeating that workspace', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('sibling'), + label: 'Sibling run', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Sibling run](dsh-session:InNpYmxpbmdyIg)', + }], + })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('sib'))).resolves.toEqual([ + expect.objectContaining({ name: 'Sibling run', description: '3d' }), + ]) + }) +}) + +describe('directory header', () => { + const drilledRequest = (query: string, quoted = false): CandidateRequest => ({ + query, + quoted, + position: 'inline', + drilled: true, + signal: new AbortController().signal, + }) + + it('publishes no header for a query the user typed', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'src/module1/', drilled: false })).toBeUndefined() + }) + + it('publishes no header until a drilled query names a directory', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'src', drilled: true })).toBeUndefined() + }) + + it('trails the workspace root down to the directory being listed', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'src/module1/ind', drilled: true })).toEqual([ + { label: 'Workspace', value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'Workspace', mention: '@' }) }, + { label: 'src', value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'src', mention: '@src/' }) }, + { + label: 'module1', + value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'module1', mention: '@src/module1/' }), + current: true, + }, + ]) + }) + + it('keeps an open quote across every crumb of a quoted descent', async () => { + const { source } = await bench() + const crumbs = source.header?.(session, { query: 'my dir/sub/', quoted: true, drilled: true }) + expect(crumbs?.map(crumb => JSON.parse(crumb.value) as { mention: string }).map(value => value.mention)) + .toEqual(['@"', '@"my dir/', '@"my dir/sub/']) + }) + + it('publishes no header when a segment cannot be written back as mention text', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'ok/we\u0001ird/', drilled: true })).toBeUndefined() + }) + + it('returns to a crumb through the same drill outcome a folder row uses', async () => { + const { source } = await bench() + const crumbs = source.header?.(session, { query: 'src/module1/', drilled: true }) + expect(source.onPick({ + candidate: { name: 'src', value: crumbs?.[1]?.value ?? '' }, + session, + position: 'inline', + via: 'menu', + action: 'drill', + span: { start: 0, end: 13, draftRev: 1 }, + })).toEqual({ text: '@src/', continue: true }) + }) + + it('drops the row location a drilled listing already shows in its header', async () => { + const files = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ path: 'src/module1/index.html', kind: 'file' as const }], + })) + const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, drilledRequest('src/module1/'))).resolves.toEqual([ + expect.objectContaining({ name: 'index.html', icon: 'file' }), + ]) + const [row] = await source.candidates(session, drilledRequest('src/module1/')) + expect(row).not.toHaveProperty('description') + }) }) describe('pick and codec', () => { @@ -320,7 +479,7 @@ describe('pick and codec', () => { it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => { const { source } = await bench() const candidates = await source.candidates(session, request('')) - const candidate = candidates.find(item => item.name === 'Session · Research')! + const candidate = candidates.find(item => item.name === 'Research')! const mention = '@[Research](dsh-session:InNvdXJjZSI)' expect(pick(source, candidate)).toEqual({ insert: { diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index cd661610fa..edaf356f28 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../context/file-reference" }, @@ -26,12 +29,21 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/workspace-path" + }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, { "path": "../ui-input-trigger" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" } diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 88729acada..997ac47de6 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index b48d2cc2f2..074ff8514d 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/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 packages/client/ui-schedule/README.md -README.md: fa7908c73edb428d2489e34978b3566791ef112b -README.zh.md: f8b4395f9a923cfc566a61c799e4be809818b9ae +README.md: 83e9bfd3cd6005636f04090e1b7fa26917a70b84 +README.zh.md: 629c83ecb55989eb0f96660b02a148b9ce1c6ea3 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index fa7908c73e..83e9bfd3cd 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -59,7 +59,7 @@ The browser plugin contributes `schedule-catalog` to `conversation.session.heade | [`src/index.ts`](src/index.ts) | Empty Host apply that keeps the optional browser feature addressable by Loader | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion; the package owns no mutable cross-plugin state | -The [read-only Web Schedule catalog Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md) owns the projection, composition, accessibility, and delivery-boundary decisions. +The [durable Web Schedule Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md) owns the active projection and opt-in presentation boundary; this package owns the catalog's timing and accessibility behavior. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index f8b4395f9a..629c83ecb5 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -59,7 +59,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml | [`src/index.ts`](src/index.ts) | 空的 Host apply,使 Loader 可以寻址该可选浏览器功能 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件;本包不拥有可变跨插件状态 | -[只读 Web Schedule 目录 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md)拥有 projection、组合、无障碍与交付边界决策。 +[持久 Web Schedule Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md)拥有活动 projection 与 opt-in 呈现边界;本包拥有目录的时间与无障碍行为。 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index b09d453e1b..2abd671289 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index fb05e11758..3e58e3bb84 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -1,6 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' -import { IconChevronDownOutline14, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconAlarmClockOutline16, + IconChevronDownOutline14, + useDismissOnOutsidePointer, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS } from './locales.ts' @@ -22,16 +26,6 @@ const UNIT_SECONDS: readonly { unit: TimeUnit; seconds: number }[] = [ SECOND_UNIT, ] -/** Minimal clock glyph kept private to this one feature. */ -function ScheduleClockIcon() { - return ( - - ) -} - /** Localized unit word for one integral magnitude. */ function unitLabel(unit: TimeUnit, value: number, t: TranslateNS): string { const keys = { @@ -157,7 +151,7 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule aria-label={countLabel} onClick={toggleCatalog} > - + {countLabel} diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 709091baed..dc4aab5b7e 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -51,6 +51,7 @@ function sessionSnapshot(openState: SessionSnapshot['openState']): SessionSnapsh return { sessionId: SESSION, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 34087f6979..7ab9428e32 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 97946f4c1d..ce6d154c0c 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { const snapshot = createSnapshotStore({ sessionId: id, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 7041d38cad..93ac13b82e 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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 packages/client/ui-settings-general/README.md -README.md: 0c42e7c8c63ee720e6766471de8e43d97624418b -README.zh.md: 09cacf4bcdd1c47b66b34840ccda2c15ce3dc2f2 +README.md: f0c4ab7c50798919d42ade0eac6a9a93c8a4df1c +README.zh.md: 912ea0fb16c0d1d512ff846c8eda1e724664f1ea diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0c42e7c8c6..f0c4ab7c50 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may ### Document availability -On a loopback page, the Client loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, browser-authenticated `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. +On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. ### Host half diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 09cacf4bcd..912ea0fb16 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 文档可用性 -在 loopback 页面上,Client 通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染配置文件操作。该操作发送无路径参数且经浏览器认证的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 +在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染配置文件操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 ### 宿主端 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 441433fadd..3f4de05dd4 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index d1342e9b1d..abdf2829b3 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -55,7 +55,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle // The shared SettingsScope mirror updates after document commits and reconnects. const documentController = connection.isLoopback - ? new SettingsDocumentStore(connection.api, ctx.settingsScope.describe()) + ? new SettingsDocumentStore(ctx.remote, ctx.settingsScope.describe()) : undefined const documentInjected = documentController === undefined ? undefined diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index b545ec66f3..4e7fec2ecd 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,6 +1,6 @@ /** State owner for the optional local settings-document action. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -32,7 +32,7 @@ export class SettingsDocumentStore { * @param describeFace - the shared mirror's describe face (`hasDocument` source). */ constructor( - private readonly api: Pick, + private readonly remote: Pick, private readonly describeFace: SettingsDescribeFace, ) {} @@ -63,8 +63,8 @@ export class SettingsDocumentStore { state.error = null }) try { - const response = await this.api.settings.openDocument({}) - if (!response.result.ok) throw new Error(response.result.error.message) + const result = await this.remote.settings.openSettingsDocument() + if (!result.ok) throw new Error(result.error.message) } catch (error) { this.store.update((state) => { state.error = messageOf(error) }) } finally { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index bf2174d571..86ec40db91 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -40,14 +40,14 @@ async function bench(isLoopback = true) { }, })) const settingsOpenDocument = vi.fn(() => Promise.resolve({ - rpcId: 'settings-open' as never, - result: { ok: true as const, value: { opened: true as const } }, + ok: true as const, value: { opened: true as const }, })) ctx.provide('connection', { - api: { settings: { openDocument: settingsOpenDocument } }, isLoopback, } as never) - new TestRemote(ctx, { settings: { describe: settingsDescribe } }) + new TestRemote(ctx, { + settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, + }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -76,7 +76,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 9c2271db4b..19464e6778 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -71,8 +71,7 @@ describe('GeneralSection', () => { describe('SettingsDocumentAction', () => { it('appears only for a file-backed provider and requests its Host-owned document', async () => { const openDocument = vi.fn(() => Promise.resolve({ - rpcId: 'document-open' as never, - result: { ok: true as const, value: { opened: true as const } }, + ok: true as const, value: { opened: true as const }, })) const controller = derivedDocumentStore({ settings: { @@ -80,7 +79,7 @@ describe('SettingsDocumentAction', () => { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, })), - openDocument, + openSettingsDocument: openDocument, }, }) render( { />) const action = await screen.findByRole('button', { name: 'Open configuration file' }) fireEvent.click(action) - await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) + await waitFor(() => { expect(openDocument).toHaveBeenCalledWith() }) }) it('stays absent without a document and follows a mirror refresh to available', async () => { const describe = vi.fn() .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }) .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }) - const wire = { settings: { describe, openDocument: vi.fn() } } as never + const wire = { settings: { describe, openSettingsDocument: vi.fn() } } as never const mirror = new SettingsDescribeMirror(wire) const controller = new SettingsDocumentStore(wire, mirror) const first = render( { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, })), - openDocument: vi.fn(() => Promise.resolve({ - rpcId: 'document-open-failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, + openSettingsDocument: vi.fn(() => Promise.resolve({ + ok: false as const, + error: { code: 'internal' as const, message: 'xdg-open missing', details: {} }, })), }, }) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index 85c55c6ad0..b166e8b452 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' @@ -13,11 +13,8 @@ function response(hasDocument = false) { return { ok: true, value: { writable: true, hasDocument, namespaces: [] } } } -function opened(): RpcResponse<{ opened: true }> { - return { - rpcId: 'settings-open' as never, - result: { ok: true, value: { opened: true } }, - } +function opened(): RemoteResult<{ opened: true }> { + return { ok: true, value: { opened: true } } } function describeFailed(message: string) { @@ -28,19 +25,19 @@ describe('SettingsDocumentStore', () => { it('loads provider metadata and asks the settings domain to open its document', async () => { const describe = vi.fn(() => Promise.resolve(response(true))) const openDocument = vi.fn(() => Promise.resolve(opened())) - const controller = derivedDocumentStore({ settings: { describe, openDocument } }) + const controller = derivedDocumentStore({ settings: { describe, openSettingsDocument: openDocument } }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', opening: false, error: null, }) await controller.open() - expect(openDocument).toHaveBeenCalledWith({}) + expect(openDocument).toHaveBeenCalledWith() }) it('marks absent or failed metadata unavailable without opening anything', async () => { const openDocument = vi.fn(() => Promise.resolve(opened())) const absent = derivedDocumentStore({ - settings: { describe: () => Promise.resolve(response()), openDocument }, + settings: { describe: () => Promise.resolve(response()), openSettingsDocument: openDocument }, }) await absent.load() await absent.open() @@ -48,13 +45,13 @@ describe('SettingsDocumentStore', () => { expect(openDocument).not.toHaveBeenCalled() const failed = derivedDocumentStore({ - settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, + settings: { describe: () => Promise.reject(new Error('offline')), openSettingsDocument: openDocument }, }) await failed.load() expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) const rejected = derivedDocumentStore({ - settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, + settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openSettingsDocument: openDocument }, }) await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ @@ -63,19 +60,16 @@ describe('SettingsDocumentStore', () => { }) it('collapses concurrent open gestures and recovers after a failure', async () => { - let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void - const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) + let resolveOpen!: (response: RemoteResult<{ opened: true }>) => void + const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) const controller = derivedDocumentStore({ - settings: { describe: () => Promise.resolve(response(true)), openDocument }, + settings: { describe: () => Promise.resolve(response(true)), openSettingsDocument: openDocument }, }) await controller.load() const first = controller.open() const second = controller.open() expect(openDocument).toHaveBeenCalledOnce() - resolveOpen({ - rpcId: 'settings-open-failed' as never, - result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }, - }) + resolveOpen({ ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }) await Promise.all([first, second]) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', opening: false, error: 'no default editor', @@ -87,7 +81,7 @@ describe('SettingsDocumentStore', () => { const controller = derivedDocumentStore({ settings: { describe: vi.fn(() => Promise.resolve(response(true))), - openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), + openSettingsDocument: () => new Promise((_, reject) => { rejectOpen = reject }), }, }) await controller.load() @@ -106,7 +100,7 @@ describe('SettingsDocumentStore', () => { describe: vi.fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(response(true)), - openDocument: vi.fn(), + openSettingsDocument: vi.fn(), }, } as never const mirror = new SettingsDescribeMirror(wire) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 89da5433ef..4bd9263dec 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -54,7 +54,9 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope']) + expect(inject).toEqual([ + 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', + ]) }) it('registers the shell and declares every child slot, before or after the declaration', async () => { diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 35e7f7557a..8b69e152a0 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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 packages/client/ui-settings-models/README.md -README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da -README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f +README.md: 75efb18daf3e4a96fea637f36633f216046ebccd +README.zh.md: f3c7099e242e379be5143352d4045208a8209dc6 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6ed2bde147..75efb18daf 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -35,13 +35,9 @@ The primary field on an editor card is a single **API key** input — the page n The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits. -### Subagent model selection - -When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision. - ### Adding and deleting providers -The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 3806244d4d..f3c7099e24 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -35,13 +35,9 @@ kind: "package-reference" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。 -### 子代理模型选择 - -当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。 - ### 新增与删除提供方 -「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 +「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 ### 首次运行弹窗 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 5366374f39..8c65aacc8f 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -47,7 +47,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -55,7 +54,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 20276412bb..a1be9f9085 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,11 +16,11 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' -import { messageOf } from './store.ts' +import { messageOf, type ModelsWire } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -80,7 +80,7 @@ export interface ModelListEditorProps { */ probeBlocked?: keyof typeof en | undefined /** Wire face the fetch action calls. */ - api: Pick + api: Pick /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ @@ -142,7 +142,7 @@ function capacitySpelling(value: number | undefined): string { } /** Adopt a candidate, keeping whatever capacities the provider disclosed. */ -function adopt(candidate: DiscoveredModelView): ModelDraft { +function adopt(candidate: LlmDiscoveredModel): ModelDraft { return { id: candidate.id, ...candidate.name === undefined ? {} : { name: candidate.name }, @@ -160,7 +160,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const { models, onChange, probe, api, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) - const [candidates, setCandidates] = useState(undefined) + const [candidates, setCandidates] = useState(undefined) const [picked, setPicked] = useState>(new Set()) // Rows carry an id and a name; capacities are the exception, so they stay // folded until asked for rather than crowding every row with four inputs. @@ -229,18 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await api.llm.discoverModels({ - settingsNs: probe.settingsNs, + const response = await api.llm.discoverModels(probe.settingsNs, { ...probe.provider === undefined ? {} : { provider: probe.provider }, ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL }, ...probe.api === undefined ? {} : { api: probe.api }, ...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey }, }) - if (!response.result.ok) { - setFailure(response.result.error.message) + if (!response.ok) { + setFailure(response.error.message) return } - const found = response.result.value.models + const found = response.value if (found.length === 0) { setFailure(t('fetchEmpty')) return diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 1c767386dd..fe2fe87d3a 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -40,87 +40,6 @@ color: var(--dsw-alias-state-success-primary); } -.preferenceCard { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px 16px; - margin-top: 4px; - padding: 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; -} - -.preferenceCopy { - min-width: 0; -} - -.preferenceTitle { - margin: 0; - font-size: 14px; - line-height: 22px; - font-weight: 500; - color: var(--dsw-alias-label-primary); -} - -.preferenceDescription { - margin: 2px 0 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -.switch { - box-sizing: border-box; - position: relative; - width: 36px; - height: 20px; - padding: 2px; - border: 0; - border-radius: 10px; - background: var(--dsw-alias-border-l3); - cursor: pointer; -} - -.switchOn { - background: var(--dsw-alias-brand-primary); -} - -.switch:disabled { - cursor: default; - opacity: 0.5; -} - -.switch:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); -} - -.switchThumb { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--dsw-alias-label-primary-foreground); - transition: transform 120ms ease; -} - -.switchOn .switchThumb { - transform: translateX(16px); -} - -.preferenceStatus, -.preferenceCard > .error { - grid-column: 1 / -1; -} - -.preferenceStatus { - margin: 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-state-success-primary); -} - .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b12fb9fc01..7b884ac906 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' -import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) - const subagentModelSelection = state.namespaces.get('subagent-model-selection') return (

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} - {subagentModelSelection === undefined - ? null - : ( - - )} {savedIdentity === undefined ? null : ( diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx deleted file mode 100644 index 8f69f41087..0000000000 --- a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** User control for model-selectable subagent delegation in new sessions. */ - -import { useState } from 'react' -import type { ReactNode } from 'react' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ModelsSettingsStore } from './store.ts' -import type { en } from './locales.ts' -import { messageOf } from './store.ts' -import styles from './ModelsSection.module.css' - -/** Props for the Host-owned subagent model-selection preference. */ -export interface SubagentModelSelectionCardProps { - /** Current redacted namespace view. */ - namespace: SettingsNamespaceView - /** Whether the settings provider accepts writes. */ - writable: boolean - /** Settings wire face. */ - api: SettingsWireFace - /** Models page controller to refresh after a commit. */ - controller: ModelsSettingsStore - /** Localized Models copy. */ - t: (key: keyof typeof en) => string -} - -/** Read the schema-validated resolved boolean from a namespace view. */ -function enabledOf(namespace: SettingsNamespaceView): boolean { - if (typeof namespace.value !== 'object' || namespace.value === null) return false - return (namespace.value as { enabled?: unknown }).enabled === true -} - -/** Render and persist the default-off new-session preference. */ -export function SubagentModelSelectionCard({ - namespace, - writable, - api, - controller, - t, -}: SubagentModelSelectionCardProps): ReactNode { - const [saving, setSaving] = useState(false) - const [saved, setSaved] = useState(false) - const [error, setError] = useState(undefined) - const enabled = enabledOf(namespace) - - const toggle = (): void => { - setSaving(true) - setSaved(false) - setError(undefined) - void api.settings.update( - namespace.ns, - { enabled: !enabled }, - namespace.revision, - ).then(async (response) => { - if (!response.ok) throw new Error(response.error.message) - controller.acceptNamespace(response.value) - await controller.load() - setSaved(true) - }).catch((reason: unknown) => { - setError(messageOf(reason)) - }).finally(() => { setSaving(false) }) - } - - return ( -
-
-

- {t('subagentModelSelectionTitle')} -

-

{t('subagentModelSelectionDescription')}

-
- - {saved - ?

{t('subagentModelSelectionSaved')}

- : null} - {error === undefined ? null :

{error}

} -
- ) -} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index b975c91f1f..c397d795c9 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -7,7 +7,6 @@ * packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -42,7 +41,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'settings.models' -export type { ModelsCredentials, ModelsSettingsState, ModelsWire, ProviderRow } from './store.ts' +export type { + ModelsCredentials, ModelsLlm, ModelsSettingsState, ModelsWire, ProviderDirectoryEntry, ProviderRow, +} from './store.ts' /** * Refetch the page snapshot only after its first load: an unopened Models @@ -60,7 +61,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * constrained; registration depends on each slot through `slots.inject()`. */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'settingsScope', 'settingsSchema', ] @@ -73,14 +74,11 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') - const connection = ctx.get('connection') as ConnectionHandle const schema = createSettingsSchemaOperations(ctx.settingsSchema) - // The page's two carriers under one face: model discovery and the catalog - // still ride the unary API, while settings and credentials are Remote - // namespaces. + // Every configuration operation rides its owning Remote namespace. const wire: ModelsWire = { - ...connection.api, credentials: ctx.remote.credentials, + llm: ctx.remote.llm, settings: ctx.remote.settings, } const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe()) diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 176e33fe5e..f1b0718ba5 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -5,10 +5,6 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', - subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', - subagentModelSelectionToggle: 'Allow subagents to choose models', - subagentModelSelectionSaved: 'Saved. New sessions use this setting.', edit: 'Edit', editProvider: 'Edit {provider}', remove: 'Delete', @@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', - subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', - subagentModelSelectionToggle: '允许 subagent 自选模型', - subagentModelSelectionSaved: '已保存,新会话将使用此设置。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', diff --git a/packages/client/ui-settings-models/src/client/slot-contract.ts b/packages/client/ui-settings-models/src/client/slot-contract.ts index b8360e21c9..a9eef19368 100644 --- a/packages/client/ui-settings-models/src/client/slot-contract.ts +++ b/packages/client/ui-settings-models/src/client/slot-contract.ts @@ -4,7 +4,7 @@ * without editing it. * * `settings.models.provider-card` is keyed by the row's owning settings - * namespace (`ConfigurableProviderView.settingsNs`): an adapter family's + * namespace (`ProviderDirectoryEntry.settingsNs`): an adapter family's * companion plugin registers one entry under the family's namespace and * receives every card of that family — shipped, added, and hand-declared rows * alike — while the section never learns what the namespace means. Keying on @@ -17,8 +17,8 @@ * the declaration. The types therefore live with their declarer. */ -import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-slots' +import type { ProviderDirectoryEntry } from './store.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -42,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of one provider-card extension occurrence. */ export interface ProviderCardExtrasOwnerProps { /** The card's directory row (route id, display name, settings address, live state). */ - provider: ConfigurableProviderView + provider: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */ configured: boolean /** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */ diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index d3b71ec6ea..fe98870321 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -1,13 +1,14 @@ /** * Models settings page store: one snapshot joining the configurable-provider - * directory (`llm.providers`), the settings namespaces (shared settings mirror), + * directory (`llm/listProviders` joined with `llm/listConfigurableProviders`), + * the settings namespaces (shared settings mirror), * and the referenced credentials (`credentials/describe`). The host stays the * single fact source — every mutation writes through the wire and the page * re-renders from the next describe, pushed or refetched. */ import type { - ClientRemote, ConfigurableProviderView, CredentialInfo, IApiClient, SettingsNamespaceView, + ClientRemote, CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -23,22 +24,71 @@ const PROBE_ROUTE = '\u0000probe' /** The credentials Remote methods the Models page reads and writes through. */ export type ModelsCredentials = Pick +/** LLM Remote methods used by the Models page. */ +export type ModelsLlm = Pick< + ClientRemote['llm'], + 'discoverModels' | 'listConfigurableProviders' | 'listProviders' +> + +/** One provider row after joining the configurable directory with live routes. */ +export interface ProviderDirectoryEntry { + readonly provider: string + readonly displayName: string + readonly settingsNs: string + readonly settingsPath: readonly string[] + readonly active: boolean + readonly declared?: boolean +} + /** - * Every wire face the Models page reaches: the settings and llm unary domains, - * plus the credentials Remote namespace, which is addressed by reference name - * and never answers with a value. + * Join declared configurable providers with the currently registered routes. + * @param registered - live provider routes in registration order. + * @param directory - declared configurable providers in declaration order. + * @returns declared rows followed by live routes with no declaration. */ -export interface ModelsWire extends Pick { +export function joinProviderDirectory( + registered: readonly LlmProviderInfo[], + directory: readonly LlmConfigurableProvider[], +): ProviderDirectoryEntry[] { + const active = new Set(registered.map(provider => provider.id)) + const declared = new Set(directory.map(entry => entry.provider)) + const rows: ProviderDirectoryEntry[] = directory.map(entry => ({ + provider: entry.provider, + displayName: entry.displayName, + settingsNs: entry.settingsNs, + settingsPath: [...entry.settingsPath], + active: active.has(entry.provider), + ...entry.declared === undefined ? {} : { declared: entry.declared }, + })) + for (const provider of registered) { + if (declared.has(provider.id)) continue + rows.push({ + provider: provider.id, + displayName: provider.name, + settingsNs: '', + settingsPath: [], + active: true, + }) + } + return rows +} + +/** + * Every Remote wire face the Models page reaches. + */ +export interface ModelsWire { /** The settings Remote namespace: the redacted read and the profile writes. */ settings: SettingsRemote /** Credential state and writes for the references provider profiles name. */ credentials: ModelsCredentials + /** Provider directory reads and draft endpoint discovery. */ + llm: ModelsLlm } /** One provider row the page renders. */ export interface ProviderRow { /** The directory entry (route id, display name, settings address, live state). */ - entry: ConfigurableProviderView + entry: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves). */ configured: boolean /** Whether the user layer alone carries the profile (removal restores the base). */ @@ -137,24 +187,15 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the page's wire faces (credentials Remote, llm reads, settings writes). + * @param api - the page's credentials Remote and LLM wire faces. * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: ModelsWire, + private readonly api: Pick, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} - /** - * Fold one successful settings write into the shared mirror before rejoining - * this page's rows. - * @param view - namespace view returned by the settings wire method. - */ - acceptNamespace(view: SettingsNamespaceView): void { - this.describeFace.acceptView(view) - } - /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over @@ -166,20 +207,22 @@ export class ModelsSettingsStore { async load(): Promise { const generation = ++this.generation this.store.update((s) => { s.status = 'loading'; s.error = null }) - let providers: ConfigurableProviderView[] + let providers: ProviderDirectoryEntry[] let writable: boolean let views: readonly SettingsNamespaceView[] try { - const [providersResponse] = await Promise.all([ - this.api.llm.providers({}), + const [registered, declared] = await Promise.all([ + this.api.llm.listProviders(), + this.api.llm.listConfigurableProviders(), this.describeFace.ensure(), ]) - if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message) + if (!registered.ok) throw new Error(registered.error.message) + if (!declared.ok) throw new Error(declared.error.message) const mirrored = this.describeFace.getSnapshot() if (mirrored.view === undefined) { throw new Error(mirrored.error ?? 'settings are unavailable in this browser') } - providers = providersResponse.result.value.providers + providers = joinProviderDirectory(registered.value, declared.value) writable = mirrored.view.writable views = mirrored.view.namespaces } catch (error) { diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 7045e4831d..4513bde5a2 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -30,6 +30,12 @@ async function bench(isLoopback = true, settings?: object, services: object = {} set: vi.fn(), unset: vi.fn(), }, + llm: { + listProviders: vi.fn(() => Promise.resolve({ ok: true, value: [] })), + listConfigurableProviders: vi.fn(() => Promise.resolve({ ok: true, value: [] })), + discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })), + ...services, + }, // Without a settings face the mirror's reads fail and stay contained; the // Models join itself never fetches until a section actually loads. The real // ui-settings apply also provides the settingsSchema service. @@ -56,7 +62,7 @@ function declare(slots: SlotRegistry): () => void { describe('ui-settings-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'settingsScope', 'settingsSchema', ]) }) @@ -302,11 +308,8 @@ describe('pushed invalidations', () => { }], }, })) - const providers = vi.fn(() => Promise.resolve({ - rpcId: 'apply-models-providers' as never, - result: { ok: true as const, value: { providers: [] } }, - })) - const b = await bench(true, { describe }, { llm: { providers } }) + const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const b = await bench(true, { describe }, { listProviders }) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.section') diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 512a6573e5..a22f787a1b 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -4,11 +4,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' -import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -134,16 +133,6 @@ function wireNamespaces(): SettingsNamespaceView[] { ] } -let nextRpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } -} -function fail(message: string, code = 'settings-rejected'): RpcResponse { - return { - rpcId: `r-${nextRpc++}` as never, - result: { ok: false, error: { code, message, details: { ns: 'x' } } as never }, - } -} /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -165,17 +154,19 @@ function scriptedFace(overrides: { const unset = overrides.unset ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) const face = { llm: { - providers: vi.fn(() => Promise.resolve(ok({ - providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, - { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, - { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false }, - { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false }, - ], - }))), - models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), + listProviders: vi.fn(() => Promise.resolve(remoteOk([ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'openai', name: 'openai' }, + ]))), + listConfigurableProviders: vi.fn(() => Promise.resolve(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, + { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false }, + { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false }, + ].map(({ active: _active, ...entry }) => entry)))), + discoverModels: vi.fn(() => Promise.resolve(remoteOk([]))), }, settings: { describe: vi.fn(() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))), @@ -316,84 +307,17 @@ describe('ModelsSection', () => { it('skips the draft seat when a refresh drops the dormant row', async () => { const { renderSlot, face, controller } = await mountSection() fireEvent.click(screen.getByRole('button', { name: en.add })) - face.llm.providers.mockImplementation(() => Promise.resolve(ok({ - providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - ], - }))) + const directory = [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, + ].map(({ active: _active, ...entry }) => entry) + face.llm.listConfigurableProviders.mockImplementation(() => Promise.resolve(remoteOk(directory))) renderSlot.mockClear() await act(async () => { await controller.load() }) // The draft card is still open while its row is gone from the directory. expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false) }) - - it('persists the default-off subagent model-selection switch for new sessions', async () => { - const enabledNamespace: SettingsNamespaceView = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - user: { enabled: true }, - revision: 5, - } - const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace))) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(toggle) - - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(update).toHaveBeenCalledWith( - 'subagent-model-selection', - { enabled: true }, - 4, - ) - expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) - }) - - it('reports rejected subagent model-selection updates and permits a retry', async () => { - const update = vi.fn() - .mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected')) - .mockResolvedValueOnce(remoteOk({ - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - revision: 5, - })) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - fireEvent.click(toggle) - expect((await screen.findByRole('alert')).textContent).toBe('revision changed') - - fireEvent.click(toggle) - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('keeps malformed and read-only subagent preferences off', () => { - const namespace = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: null, - } as unknown as SettingsNamespaceView - const mutate = vi.fn() - render( - , - ) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect((toggle as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(toggle) - expect(mutate).not.toHaveBeenCalled() - }) - it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { await mountFirstRun() // Nothing is reachable yet, and DeepSeek has no configured credential and @@ -515,7 +439,7 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() // The saved key re-loads the join; the settings answer rides the shared // mirror, so the reload shows as a directory read rather than a describe. - await waitFor(() => { expect(face.llm.providers.mock.calls.length).toBeGreaterThan(1) }) + await waitFor(() => { expect(face.llm.listProviders.mock.calls.length).toBeGreaterThan(1) }) expect((await screen.findByRole('status')).textContent).toBe( providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }), ) @@ -1321,7 +1245,7 @@ describe('ModelsSection', () => { it('renders the load failure with a retry control', async () => { const face = scriptedFace() - face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never + face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'internal'))) as never const controller = new ModelsSettingsStore( face.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face.face as never)) await controller.load() diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index d45736cd6d..54b7bdd815 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' @@ -17,10 +17,6 @@ afterEach(() => { document.getElementById('root')?.remove() }) -let nextRpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } } -} /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -91,20 +87,25 @@ function harness(options: { }) const face = { llm: { - providers: () => { + listProviders: () => { if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable')) - return Promise.resolve(ok({ - providers: options.provider === false + return Promise.resolve(remoteOk( + options.provider === false || options.providerActive === false ? [] - : [{ - provider: 'deepseek-official', - displayName: 'DeepSeek', - settingsNs: options.providerSettingsNs ?? 'llm-deepseek', - settingsPath: [], - active: options.providerActive ?? true, - }], - })) + : [{ id: 'deepseek-official', name: 'DeepSeek' }], + )) }, + listConfigurableProviders: () => Promise.resolve(remoteOk( + options.provider === false + ? [] + : [{ + provider: 'deepseek-official', + displayName: 'DeepSeek', + settingsNs: options.providerSettingsNs ?? 'llm-deepseek', + settingsPath: [], + }], + )), + discoverModels: () => Promise.resolve(remoteOk([])), }, settings: { describe: () => Promise.resolve(remoteOk({ diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index e6168398af..0bf6b59861 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' @@ -38,12 +38,11 @@ const PiAiConfig = Schema.object({ })), }) -let nextRpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } +function ok(value: T) { + return { ok: true as const, value } } -function fail(message: string, code: string): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } } +function fail(message: string, code: string) { + return { ok: false as const, error: { code, message, details: {} } } } /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { @@ -88,22 +87,23 @@ function scriptedFace(options: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' }, } const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {}) - const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] }))) + const discover = options.discover ?? vi.fn(() => Promise.resolve(ok([]))) const mutate = options.mutate ?? vi.fn(() => Promise.resolve(remoteOk(namespace))) const set = options.set ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) const face = { llm: { - providers: vi.fn(() => Promise.resolve(ok({ - providers: Object.keys(providers).map(provider => ({ + listProviders: vi.fn(() => Promise.resolve(ok( + Object.keys(providers).map(provider => ({ id: provider, name: provider })), + ))), + listConfigurableProviders: vi.fn(() => Promise.resolve(ok( + Object.keys(providers).map(provider => ({ provider, displayName: provider, settingsNs: 'llm-pi-ai', settingsPath: ['providers', provider], - active: true, declared: options.declaredRoutes?.includes(provider) ?? false, })), - }))), - models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), + ))), discoverModels: discover, }, settings: { @@ -132,9 +132,9 @@ interface MutateCall { /** The first interrogation payload; fails the case when nothing was asked. */ function firstProbe(discover: ReturnType): unknown { - const call = (discover.mock.calls as unknown as [unknown][])[0]?.[0] + const call = (discover.mock.calls as unknown as [string, Record][])[0] if (call === undefined) throw new Error('no interrogation was recorded') - return call + return { settingsNs: call[0], ...call[1] } } /** @@ -440,7 +440,7 @@ describe('capacity spellings', () => { describe('endpoint interrogation', () => { it('asks the endpoint the form shows, with a key that is not yet stored', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'acme-large', contextWindow: 65_536 }] }))) + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'acme-large', contextWindow: 65_536 }]))) await mountSection({ discover }) openEditor('openai') @@ -460,7 +460,7 @@ describe('endpoint interrogation', () => { }) it('carries the protocol the profile already names', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [] }))) + const discover = vi.fn(() => Promise.resolve(ok([]))) await mountSection({ discover, providers: { openai: { baseURL: 'https://proxy.example/v1', api: 'openai-responses' } }, @@ -479,9 +479,9 @@ describe('endpoint interrogation', () => { }) it('adopts only the picked candidates, keeping a row the user already tuned', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ - models: [{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }], - }))) + const discover = vi.fn(() => Promise.resolve(ok([ + { id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }, + ]))) const { mutate } = await mountSection({ discover, providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept', contextWindow: 111 }] } }, @@ -518,7 +518,7 @@ describe('endpoint interrogation', () => { }) it('reports an empty listing and a rejected transport', async () => { - const empty = vi.fn(() => Promise.resolve(ok({ models: [] }))) + const empty = vi.fn(() => Promise.resolve(ok([]))) await mountSection({ discover: empty }) openEditor('openai') fireEvent.click(screen.getByText(en.fetchModels)) @@ -533,7 +533,7 @@ describe('endpoint interrogation', () => { }) it('can be asked for a configured route even with no endpoint', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'from-registry' }] }))) + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'from-registry' }]))) await mountSection({ discover, providers: { openai: {} } }) openEditor('openai') @@ -585,7 +585,7 @@ describe('endpoint interrogation', () => { }) it('closes the picker without adopting anything on cancel', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'fresh' }] }))) + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'fresh' }]))) const { mutate } = await mountSection({ discover }) openEditor('openai') @@ -599,9 +599,9 @@ describe('endpoint interrogation', () => { }) it('toggles a candidate off and back on before adopting', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ - models: [{ id: 'a' }, { id: 'b', maxTokens: 2048 }], - }))) + const discover = vi.fn(() => Promise.resolve(ok([ + { id: 'a' }, { id: 'b', maxTokens: 2048 }, + ]))) const { mutate } = await mountSection({ discover }) openEditor('openai') @@ -620,9 +620,9 @@ describe('endpoint interrogation', () => { }) it('selects and clears every discovered candidate in one action', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ - models: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], - }))) + const discover = vi.fn(() => Promise.resolve(ok([ + { id: 'a' }, { id: 'b' }, { id: 'c' }, + ]))) await mountSection({ discover }) openEditor('openai') @@ -664,15 +664,12 @@ describe('provider rows', () => { it('shows no tag when the adapter draws no catalog distinction', async () => { const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) - scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({ - providers: [{ - provider: 'openai', - displayName: 'openai', - settingsNs: 'llm-pi-ai', - settingsPath: ['providers', 'openai'], - active: true, - }], - }))) as never + scripted.face.llm.listConfigurableProviders = vi.fn(() => Promise.resolve(ok([{ + provider: 'openai', + displayName: 'openai', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai'], + }]))) as never const controller = new ModelsSettingsStore( scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never)) await controller.load() @@ -828,16 +825,13 @@ describe('hand-declared providers', () => { }) // The reload after the write answers with the renamed route, exactly as // the adapter re-registers it. - face.llm.providers = vi.fn(() => Promise.resolve(ok({ - providers: [{ - provider: 'acme-gateway', - displayName: 'Acme 网关', - settingsNs: 'llm-pi-ai', - settingsPath: ['providers', 'acme-gateway'], - active: true, - declared: true, - }], - }))) + face.llm.listConfigurableProviders = vi.fn(() => Promise.resolve(ok([{ + provider: 'acme-gateway', + displayName: 'Acme 网关', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'acme-gateway'], + declared: true, + }]))) openEditor('acme-gateway') fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } }) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 677ca14418..5f9aced5bb 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -58,10 +58,33 @@ function api(overrides: { describeCredentials?: (refs: readonly string[]) => Promise>> } = {}) { const seenRefs: string[][] = [] + const providers = overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))) + let providerBatch: Promise> | undefined + let providerBatchReads = 0 + const readProviderBatch = (): Promise> => { + providerBatch ??= providers() + const current = providerBatch + providerBatchReads += 1 + if (providerBatchReads % 2 === 0) providerBatch = undefined + return current + } + const mapProviderBatch = async ( + project: (rows: typeof DIRECTORY) => T, + ): Promise> => { + const response = await readProviderBatch() + return response.result.ok + ? remoteOk(project(response.result.value.providers)) + : remoteFail(response.result.error.message) + } const face = { llm: { - providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))), - models: () => Promise.resolve(ok({ groups: [], failures: [] })), + listProviders: () => mapProviderBatch(rows => rows + .filter(row => row.active) + .map(row => ({ id: row.provider, name: row.displayName }))), + listConfigurableProviders: () => mapProviderBatch(rows => rows + .filter(row => row.settingsNs !== '') + .map(({ active: _active, ...row }) => row)), + discoverModels: () => Promise.resolve(remoteOk([])), }, settings: { describe: overrides.describeSettings @@ -158,6 +181,21 @@ describe('ModelsSettingsStore', () => { expect(store.store.getSnapshot().status).toBe('ready') }) + it('surfaces a configurable-provider directory failure', async () => { + const { face, mirror } = api() + const llm = (face as unknown as { + llm: { listConfigurableProviders: () => Promise> } + }).llm + llm.listConfigurableProviders = () => Promise.resolve(remoteFail('configuration directory down')) + const store = new ModelsSettingsStore(face, settingsSchema, mirror) + + await store.load() + + expect(store.store.getSnapshot()).toMatchObject({ + status: 'error', error: 'configuration directory down', + }) + }) + it('lets the newest load win over a stale slow response', async () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 270e353444..1049448e57 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 150c835953..cd36155215 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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 packages/client/ui-settings-plugins/README.md -README.md: 17ef6fda253ead749eba297519ad4b0647fc482e -README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72 +README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f +README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 17ef6fda25..8453bd4cb8 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). +Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`). ### What appears here @@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot ### Editing and saving -A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. +A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. + +The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. ### Secret-role fields @@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom ### The write path -Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 9f48f673c6..6a9b716b84 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ### 这里会出现什么 @@ -33,7 +33,9 @@ kind: "package-reference" ### 编辑与保存 -卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 +卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。 + +Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 ### secret 角色字段 @@ -55,7 +57,7 @@ kind: "package-reference" ### 写入路径 -保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 +保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 75b87d26fb..c0cdbaf6b7 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -47,6 +48,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -55,6 +57,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx index 6965939561..472a9f0e85 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx @@ -14,7 +14,7 @@ * disabled card the user cannot act on. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { CardShell } from './card-form.ts' @@ -46,7 +46,19 @@ export interface PluginCardProps { */ export function PluginCard(props: PluginCardProps) { const [open, setOpen] = useState(false) + const saveStarted = useRef(false) const { state } = props + // Collapse only after Host-confirmed settlement; a rejected write keeps its + // diagnostics and retained drafts visible for correction. + useEffect(() => { + if (state.saving) { + saveStarted.current = true + return + } + if (!saveStarted.current) return + saveStarted.current = false + if (!state.dirty && !state.failed) setOpen(false) + }, [state.dirty, state.failed, state.saving]) if (!state.available) return null const title = props.t(props.titleKey) const blocked = !state.dirty || state.invalid || state.saving diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css new file mode 100644 index 0000000000..3508ec8e48 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -0,0 +1,176 @@ +.permission { + display: grid; + gap: 6px; + padding: 12px 0; +} + +.toggleRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + font-size: 13px; + line-height: 1.5; + color: var(--dsw-alias-label-secondary); +} + +.toggleLabel { + flex: 1; + min-width: 0; +} + +.switch { + box-sizing: border-box; + position: relative; + flex: 0 0 auto; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .thumb { + transform: translateX(16px); +} + +.selection { + display: grid; + gap: 10px; +} + +.hint, +.notice, +.invalid, +.conflict { + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.hint, +.notice { + color: var(--dsw-alias-label-tertiary); +} + +.invalid, +.conflict { + color: var(--dsw-alias-label-error); +} + +.catalogError { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: var(--dsw-alias-label-error); +} + +.catalogError button { + border: 0; + padding: 0; + background: transparent; + color: var(--dsw-alias-brand-primary); + cursor: pointer; +} + +.models { + display: grid; + gap: 6px; + min-width: 0; + max-height: 280px; + margin: 0; + padding: 10px; + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.models legend { + padding: 0 4px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); +} + +.modelGroup { + display: grid; + gap: 6px; +} + +.modelGroup + .modelGroup { + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.providerName { + padding: 0 6px; + font-size: 11px; + font-weight: 500; + color: var(--dsw-alias-label-tertiary); +} + +.model { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px; + border-radius: 6px; + cursor: pointer; +} + +.model:hover { + background: var(--dsw-alias-bg-layer-4); +} + +.modelName, +.route { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelName { + font-size: 13px; + color: var(--dsw-alias-label-primary); +} + +.route { + margin-top: 2px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} + +.unavailable { + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx new file mode 100644 index 0000000000..e5ba1ffc11 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -0,0 +1,143 @@ +/** User control for model-selectable subagent delegation in new sessions. */ + +import clsx from 'clsx' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + SubagentModelCandidate, + SubagentModelSelectionCardFace, +} from './subagent-model-selection-card-controller.ts' +import type {} from './slot-contract.ts' +import { PluginCard } from './PluginCard.tsx' +import css from './SubagentModelSelectionCard.module.css' + +/** Props the renderer binds for the subagent model-selection card. */ +export type SubagentModelSelectionCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.plugins'> + & InjectFace + +/** + * Render the default-off preference and its exact adapter-route choices. + * @param props - locale copy, the card snapshot, and its toggle action. + * @returns the preference card, or nothing when the namespace is unavailable. + */ +export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { + const { t } = props + const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + const availableGroups = new Map() + const unavailable: SubagentModelCandidate[] = [] + for (const candidate of state.candidates) { + if (!candidate.available) { + unavailable.push(candidate) + continue + } + const group = availableGroups.get(candidate.provider) + if (group === undefined) { + availableGroups.set(candidate.provider, { + providerName: candidate.providerName, + candidates: [candidate], + }) + } else { + group.candidates.push(candidate) + } + } + const renderCandidate = (candidate: SubagentModelCandidate) => ( + + ) + return ( + +
+
+ {t('subagentModelSelectionToggle')} + +
+

+ {t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')} +

+
+ {state.enabled + ? ( +
+ {state.catalogStatus === 'loading' + ?

{t('subagentModelSelectionLoading')}

+ : null} + {state.catalogStatus === 'error' + ? ( +
+ {t('subagentModelSelectionLoadFailed')} + +
+ ) + : null} + {state.catalogPartial + ?

{t('subagentModelSelectionPartial')}

+ : null} + {state.candidates.length > 0 + ? ( +
+ {t('subagentModelSelectionAllowed')} + {[...availableGroups].map(([provider, group]) => ( +
+
{group.providerName}
+ {group.candidates.map(renderCandidate)} +
+ ))} + {unavailable.length > 0 + ? ( +
+
{t('subagentModelSelectionUnavailableGroup')}
+ {unavailable.map(renderCandidate)} +
+ ) + : null} +
+ ) + : state.catalogStatus === 'ready' + ?

{t('subagentModelSelectionEmpty')}

+ : null} + {state.invalid ?

{t('subagentModelSelectionRequired')}

: null} +
+ ) + : null} + {state.conflicted + ?

{t('subagentModelSelectionConflict')}

+ : null} +
+ ) +} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 11dc449b3e..40ec376432 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -4,7 +4,7 @@ * * The section declares `settings.plugins.tab`; its own `configurable` tab then * declares `settings.plugin.item` and renders whatever cards were registered - * into it. The three cards this package ships are the host-plane sections the + * into it. The cards this package ships are the host-plane sections the * deployment already exposes; each binds its namespace through the client * settings scope, which keeps them unaware of one another and of other tabs. */ @@ -25,10 +25,14 @@ import { BashCard } from './BashCard.tsx' import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' import { SHELL_NS, BashCardController } from './bash-card-controller.ts' import { ConfigurablePluginsTabController } from './tab-store.ts' +import { + SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, +} from './subagent-model-selection-card-controller.ts' import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' @@ -49,7 +53,9 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] +export const inject = [ + 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', +] /** * Mount the plugin configuration section and the cards this package ships. @@ -63,6 +69,10 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController( ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) + const subagentModelSelection = new SubagentModelSelectionCardController( + ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + ctx.remote.session, + ) // The credential a card reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that @@ -71,6 +81,19 @@ export function apply(ctx: ClientContext): void { () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) + ctx.effect( + () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent adapter invalidations', + ) + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent settings invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }), + 'ui-settings-plugins: subagent connection generation', + ) + ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') // The shared SettingsScope mirror updates after document commits and reconnects. const configurable = new ConfigurablePluginsTabController( @@ -130,7 +153,7 @@ export function apply(ctx: ClientContext): void { }, PluginsSettingsSection)) // The existing configuration page is one ordinary tab. It keeps ownership - // of the card slot and the three shipped card contributions below. + // of the card slot and the shipped card contributions below. ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'configurable', @@ -154,6 +177,12 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => agentLoop.inject(), }, AgentLoopCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + key: SUBAGENT_MODEL_SELECTION_NS, + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: WEB_SEARCH_NS, diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 1478e39997..b68ab80beb 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -11,6 +11,12 @@ export type PluginsSettingsLocaleKey = | 'webSearchTitle' | 'webSearchDescription' | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' + | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' + | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' + | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' /** English copy. */ export const en: Record = { @@ -51,6 +57,21 @@ export const en: Record = { webSearchBaseUrlHint: 'Leave blank to use the provider default.', webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: 'Control which models agents may choose for subagents.', + subagentModelSelectionToggle: 'Allow agents to choose models for subagents', + subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.', + subagentModelSelectionAllowed: 'Models agents may choose', + subagentModelSelectionLoading: 'Loading models…', + subagentModelSelectionLoadFailed: 'Models could not be loaded.', + subagentModelSelectionRetry: 'Retry', + subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', + subagentModelSelectionUnavailable: 'Currently unavailable', + subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', + subagentModelSelectionEmpty: 'No model provider currently advertises a model.', + subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', + subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } /** Simplified Chinese copy. */ @@ -92,4 +113,19 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。', + subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', + subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', + subagentModelSelectionAllowed: 'Agent 可选择的模型', + subagentModelSelectionLoading: '正在加载模型…', + subagentModelSelectionLoadFailed: '无法加载模型。', + subagentModelSelectionRetry: '重试', + subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', + subagentModelSelectionUnavailable: '当前不可用', + subagentModelSelectionUnavailableGroup: '已保存但当前不可用', + subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', + subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', + subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts new file mode 100644 index 0000000000..9e1b5c2d2a --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -0,0 +1,360 @@ +/** Staged editor for the Host-owned subagent model allowlist. */ + +import type { + ClientRemote, + ModelProviderGroup, +} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { CardShell } from './card-form.ts' + +/** Namespace of the Host-owned subagent model-selection preference. */ +export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' + +/** One exact provider/model route stored as user authorization. */ +export interface AllowedSubagentModel { + provider: string + model: string +} + +/** Settings fields stored for subagent model selection. */ +export interface SubagentModelSelectionSettings { + /** Whether model-facing child route selection applies to new Sessions. */ + enabled: boolean + /** Exact child routes offered to newly composed top-level Sessions. */ + allowedModels: AllowedSubagentModel[] +} + +/** One catalog row joined with a stored route that may no longer be advertised. */ +export interface SubagentModelCandidate extends AllowedSubagentModel { + /** Stable opaque identity used only for lookup. */ + key: string + /** Adapter-owned provider display name. */ + providerName: string + /** Adapter-owned model display name. */ + modelName: string + /** Whether the current adapter catalog advertises this exact route. */ + available: boolean + /** Whether the current draft authorizes this route. */ + selected: boolean +} + +/** State rendered by the staged allowlist card. */ +export interface SubagentModelSelectionCardState extends CardShell { + /** Whether the draft enables model-facing child route selection. */ + enabled: boolean + /** Live catalog joined with stored routes. */ + candidates: readonly SubagentModelCandidate[] + /** Adapter-directory request state. */ + catalogStatus: 'idle' | 'loading' | 'ready' | 'error' + /** Whether any provider-local catalog request failed. */ + catalogPartial: boolean + /** Whether a newer Host revision invalidated the current draft. */ + conflicted: boolean +} + +/** Registration-side face for the subagent model-selection card. */ +export interface SubagentModelSelectionCardFace { + hooks: { + /** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */ + subagentModelSelectionCard: SnapshotStore + } + /** Stage the enabled state; enabling also loads the adapter directory. */ + toggleEnabled: () => void + /** Stage one exact route as allowed or denied. */ + toggleModel: (key: string) => void + /** Retry the adapter directory. */ + retryCatalog: () => void + /** Persist the switch and exact routes as one revision-fenced mutation. */ + save: () => void + /** Drop the staged enabled state and route choices. */ + discard: () => void +} + +/** + * Stable identity for one exact route; callers resolve it by lookup and never parse it. + * @param route - Provider/model route to identify. + * @returns Opaque key for lookup within the card. + */ +export function subagentModelKey(route: AllowedSubagentModel): string { + return `${route.provider}\0${route.model}` +} + +/** + * Join live adapter metadata with stored routes that remain removable after disappearance. + * @param groups - Current model directory grouped by provider. + * @param stored - Routes in the effective settings value. + * @param selected - Opaque route keys selected in the current draft. + * @returns Candidate rows for the card. + */ +export function subagentModelCandidates( + groups: readonly ModelProviderGroup[], + stored: readonly AllowedSubagentModel[], + selected: ReadonlySet, +): SubagentModelCandidate[] { + const storedByKey = new Map(stored.map(route => [subagentModelKey(route), route])) + const candidates = groups.flatMap(group => group.models.map((model): SubagentModelCandidate => { + const route = { provider: group.id, model: model.id } + const key = subagentModelKey(route) + storedByKey.delete(key) + return { + ...route, + key, + providerName: group.name, + modelName: model.name, + available: true, + selected: selected.has(key), + } + })) + for (const route of storedByKey.values()) { + const key = subagentModelKey(route) + candidates.push({ + ...route, + key, + providerName: route.provider, + modelName: route.model, + available: false, + selected: selected.has(key), + }) + } + return candidates +} + +function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly AllowedSubagentModel[]): boolean { + if (left.length !== right.length) return false + const rightKeys = new Set(right.map(subagentModelKey)) + return left.every(route => rightKeys.has(subagentModelKey(route))) +} + +/** Bridges one settings scope and the live adapter directory onto a staged card. */ +export class SubagentModelSelectionCardController { + private catalogGroups: readonly ModelProviderGroup[] = [] + private catalogPartial = false + private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' + private draftEnabled: boolean | undefined + private draftRoutes: Map | undefined + private draftRevision: number | undefined + private saving = false + private failed = false + private conflicted = false + private disposed = false + private saveGeneration = 0 + private catalogGeneration = 0 + private readonly store: SnapshotStore + private readonly unsubscribe: () => void + + /** + * @param scope - bound `subagent-model-selection` settings scope. + * @param session - Host Session model-catalog face. + */ + constructor( + private readonly scope: SettingsScope, + private readonly session: Pick, + ) { + this.store = createSnapshotStore(this.projection()) + this.unsubscribe = scope.subscribe(() => { + if (!this.saving && this.draftRoutes !== undefined + && this.scope.getSnapshot().revision !== this.draftRevision) { + if (this.currentEnabled() === this.enabled() + && sameRoutes(this.currentRoutes(), this.desiredRoutes())) this.clearDraft() + else this.conflicted = true + } + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + }) + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() + } + + /** Stop observing settings and suppress late directory/write settlements. */ + dispose(): void { + this.disposed = true + this.saveGeneration += 1 + this.catalogGeneration += 1 + this.unsubscribe() + } + + /** + * Build the renderer face for this card. + * @returns The snapshot and staged card actions injected into the renderer. + */ + inject(): SubagentModelSelectionCardFace { + return { + hooks: { subagentModelSelectionCard: this.store }, + toggleEnabled: () => { this.toggleEnabled() }, + toggleModel: (key) => { this.toggleModel(key) }, + retryCatalog: () => { void this.loadCatalog() }, + save: () => { void this.save() }, + discard: () => { this.discard() }, + } + } + + private currentRoutes(): AllowedSubagentModel[] { + return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? [] + } + + private currentEnabled(): boolean { + return this.scope.getSnapshot().value?.enabled ?? false + } + + private selected(): Set { + return new Set(this.draftRoutes?.keys() ?? this.currentRoutes().map(subagentModelKey)) + } + + private enabled(): boolean { + return this.draftEnabled ?? this.currentEnabled() + } + + private beginDraft(): Map { + if (this.draftRoutes === undefined) { + const snapshot = this.scope.getSnapshot() + this.draftEnabled = snapshot.value?.enabled ?? false + this.draftRoutes = new Map( + snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [], + ) + this.draftRevision = snapshot.revision + } + return this.draftRoutes + } + + private toggleEnabled(): void { + const snapshot = this.scope.getSnapshot() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return + this.beginDraft() + this.draftEnabled = !this.draftEnabled + this.failed = false + if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + } + + private toggleModel(key: string): void { + if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return + const candidate = this.candidates().find(candidate => candidate.key === key) + if (candidate === undefined) return + const routes = this.beginDraft() + if (routes.has(key)) routes.delete(key) + else routes.set(key, { provider: candidate.provider, model: candidate.model }) + this.failed = false + this.publish() + } + + private clearDraft(): void { + this.draftEnabled = undefined + this.draftRoutes = undefined + this.draftRevision = undefined + this.failed = false + this.conflicted = false + } + + private discard(): void { + if (this.saving) return + this.clearDraft() + this.publish() + } + + private candidates(): SubagentModelCandidate[] { + const retained = new Map(this.currentRoutes().map(route => [subagentModelKey(route), route])) + for (const [key, route] of this.draftRoutes ?? []) retained.set(key, route) + return subagentModelCandidates(this.catalogGroups, [...retained.values()], this.selected()) + } + + private desiredRoutes(): AllowedSubagentModel[] { + return [...this.draftRoutes?.values() ?? this.currentRoutes()].map(route => ({ ...route })) + } + + private async save(): Promise { + const snapshot = this.scope.getSnapshot() + const desiredEnabled = this.enabled() + const desired = this.desiredRoutes() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving + || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) + || (desiredEnabled && desired.length === 0)) return + if (this.draftRoutes !== undefined && snapshot.revision !== this.draftRevision) { + this.conflicted = true + this.publish() + return + } + const generation = this.saveGeneration + this.saving = true + this.failed = false + this.conflicted = false + this.publish() + await this.scope.mutate([ + { op: 'set', path: ['enabled'], value: desiredEnabled }, + { + op: 'set', + path: ['allowedModels'], + value: desired.map(route => ({ provider: route.provider, model: route.model })), + }, + ], this.draftRevision) + if (generation !== this.saveGeneration) return + const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) + this.saving = false + this.failed = !landed + if (landed) this.clearDraft() + this.publish() + } + + /** Invalidate and reload model candidates after a Host model input changes. */ + refreshCatalog(): void { + if (this.disposed) return + this.catalogGeneration += 1 + this.catalogStatus = 'idle' + this.catalogPartial = false + if (this.enabled()) void this.loadCatalog() + else this.publish() + } + + /** Drop Host-specific candidates and drafts, then reload after reconnecting. */ + resetConnection(): void { + if (this.disposed) return + this.saveGeneration += 1 + this.saving = false + this.clearDraft() + this.catalogGroups = [] + this.refreshCatalog() + } + + private async loadCatalog(): Promise { + if (this.disposed || this.catalogStatus === 'loading') return + const generation = this.catalogGeneration + this.catalogStatus = 'loading' + this.catalogPartial = false + this.publish() + try { + const response = await this.session.modelCatalog() + if (generation !== this.catalogGeneration) return + if (!response.ok) throw new Error(response.error.message) + this.catalogGroups = response.value.groups + this.catalogPartial = response.value.failures.length > 0 + this.catalogStatus = 'ready' + } catch { + if (generation !== this.catalogGeneration) return + this.catalogStatus = 'error' + } + this.publish() + } + + private projection(): SubagentModelSelectionCardState { + const snapshot = this.scope.getSnapshot() + const current = this.currentRoutes() + const desired = this.desiredRoutes() + const enabled = this.enabled() + return { + available: snapshot.status === 'ready', + writable: snapshot.writable, + dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired), + invalid: enabled && desired.length === 0, + saving: this.saving, + failed: this.failed, + enabled, + candidates: this.candidates(), + catalogStatus: this.catalogStatus, + catalogPartial: this.catalogPartial, + conflicted: this.conflicted, + } + } + + private publish(): void { + this.store.set(this.projection()) + } +} diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index bba43edcbc..6bc979abc6 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -27,6 +28,9 @@ async function bench(served?: string[]) { locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) + const models = vi.fn(() => Promise.resolve({ + ok: true as const, value: { groups: [], failures: [] }, + })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } : { @@ -41,11 +45,16 @@ async function bench(served?: string[]) { })) const remote = new TestRemote(ctx, { credentials: { describe: describeCredentials, set: vi.fn() }, + session: { modelCatalog: models }, settings: { describe: describeSettings }, }) - ctx.provide('connection', { isLoopback: true, api: {} } as never) + ctx.provide('connection', { + isLoopback: true, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } + return { + ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, + } } function declareRoot(slots: SlotRegistry): () => void { @@ -57,7 +66,9 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) + expect(inject).toEqual([ + 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', + ]) }) it('registers one Plugins section and declares the tab and card slots', async () => { @@ -117,7 +128,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['shell', 'agent-loop', 'web-search-deepseek']) + .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek']) }) it('dispatches the served namespaces its cards claim, and no others', async () => { @@ -176,6 +187,23 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) + it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { + const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') + const { ctx, slots, remote } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + refresh.mockClear() + reset.mockClear() + + remote.emit('llm/adapters-updated', []) + expect(refresh).toHaveBeenCalledTimes(1) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(refresh).toHaveBeenCalledTimes(2) + ctx.emit('connection/reset') + expect(reset).toHaveBeenCalledTimes(1) + }) + it('ignores a credential change for a reference no card watches', async () => { const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) @@ -203,7 +231,7 @@ describe('ui-settings-plugins apply', () => { declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(slots.entries('settings.plugin.item')).toHaveLength(3) + expect(slots.entries('settings.plugin.item')).toHaveLength(4) await fiber.dispose() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index ad666e9093..e84a00cf49 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' +import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' @@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts' import type { CardFieldState, CardShell } from '../src/client/card-form.ts' import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts' import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -66,7 +69,7 @@ function renderConfigurable(namespaces: string[], cards: Record render() } -function renderBash(state: Partial = {}) { +function renderBashCard(state: Partial = {}) { const store = createSnapshotStore({ ...settled, timeoutMs: field('60000'), @@ -76,6 +79,36 @@ function renderBash(state: Partial = {}) { const actions = cardActions() const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() + return { actions, store } +} + +function renderBash(state: Partial = {}) { + return renderBashCard(state).actions +} + +function renderSubagentModelSelection(state: Partial = {}) { + const store = createSnapshotStore({ + ...settled, + enabled: false, + candidates: [], + catalogStatus: 'idle', + catalogPartial: false, + conflicted: false, + ...state, + }) + const actions = { + toggleEnabled: vi.fn(), + toggleModel: vi.fn(), + retryCatalog: vi.fn(), + save: vi.fn(), + discard: vi.fn(), + } + const props = { + ...actions, + t, + useSubagentModelSelectionCard: bindSnapshotSelector(store), + } as unknown as SubagentModelSelectionCardProps + render() return actions } @@ -292,6 +325,137 @@ describe('BashCard', () => { expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() }) + + it('collapses after a successful save settles', () => { + const { actions, store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + expect(actions.save).toHaveBeenCalledOnce() + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) }) + + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + }) + + it('keeps a failed save open', () => { + const { store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) }) + + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + expect(screen.getByText(en.saveFailed)).toBeTruthy() + }) +}) + +describe('SubagentModelSelectionCard', () => { + it('renders the default-off preference in its staged plugin card', () => { + const actions = renderSubagentModelSelection() + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(control.getAttribute('aria-checked')).toBe('false') + fireEvent.click(control) + + expect(actions.toggleEnabled).toHaveBeenCalledOnce() + }) + + it('groups available adapter candidates by provider', () => { + const actions = renderSubagentModelSelection({ + enabled: true, + candidates: [ + { + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }, + { + key: 'alpha\0deep', + provider: 'alpha', + model: 'deep', + providerName: 'Alpha API', + modelName: 'Deep', + available: true, + selected: false, + }, + ], + catalogStatus: 'ready', + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') + expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() + fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') + }) + + it('renders directory progress, failures, unavailable routes, and validation', () => { + renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() + + cleanup() + const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) + expect(errorActions.retryCatalog).toHaveBeenCalledOnce() + + cleanup() + renderSubagentModelSelection({ + enabled: true, + catalogStatus: 'ready', + catalogPartial: true, + candidates: [{ + key: 'legacy\0old', + provider: 'legacy', + model: 'old', + providerName: 'legacy', + modelName: 'old', + available: false, + selected: true, + }], + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() + + cleanup() + renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() + }) + + it('distinguishes a stale draft from a rejected save', () => { + renderSubagentModelSelection({ dirty: true, conflicted: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() + expect(screen.queryByText(en.saveFailed)).toBeNull() + }) + + it('stays hidden when unavailable and disables writes when read-only', () => { + renderSubagentModelSelection({ available: false }) + expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() + + cleanup() + const actions = renderSubagentModelSelection({ writable: false }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + const control = screen.getByRole('switch') as HTMLButtonElement + expect(control.disabled).toBe(true) + fireEvent.click(control) + expect(actions.toggleEnabled).not.toHaveBeenCalled() + }) }) describe('AgentLoopCard', () => { diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 505079be0c..ffdb0d8cf3 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' @@ -12,6 +13,11 @@ import { SettingsDescribeMirror, type SettingsMirrorSnapshot, } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' +import { + SubagentModelSelectionCardController, + subagentModelCandidates, + type SubagentModelSelectionSettings, +} from '../src/client/subagent-model-selection-card-controller.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -21,6 +27,18 @@ function acceptWrites(host: StubSettingsScope): void { host.set.mockImplementation((field: string, value: unknown) => { host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) }) + host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => { + const value = { ...section() } + const user = { ...layer() } + for (const op of ops) { + const field = op.path[0]! + if (op.op === 'set') { + value[field] = op.value + user[field] = op.value + } + } + host.publish({ value: value as T, user }) + }) host.unset.mockImplementation((field: string) => { const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) const base = host.scope.getSnapshot().base as Record | undefined @@ -37,6 +55,33 @@ function credentialsApi(configured: boolean) { return { api: { describe, set } as never, describe, set } } +function modelsApi(options: { + groups?: readonly { + id: string + name: string + models: readonly { id: string; name: string }[] + }[] + failures?: readonly { id: string; name: string; message: string }[] + error?: string +} = {}) { + const models = vi.fn(() => Promise.resolve({ + ...(options.error === undefined + ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } + : { ok: false as const, error: { code: 'internal' as const, message: options.error, details: {} } }), + })) + return { api: { modelCatalog: models } as never, models } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, resolve, reject } +} + describe('CardForm', () => { function form() { const host = stubSettingsScope>() @@ -383,6 +428,426 @@ describe('AgentLoopCardController', () => { }) }) +describe('SubagentModelSelectionCardController', () => { + it('joins stored routes with the live catalog without dropping unavailable choices', () => { + const candidates = subagentModelCandidates( + [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + [{ provider: 'legacy', model: 'old' }], + new Set(['legacy\0old']), + ) + + expect(candidates).toEqual([ + { + key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API', + modelName: 'Fast', available: true, selected: false, + }, + { + key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy', + modelName: 'old', available: false, selected: true, + }, + ]) + }) + + it('loads adapter models and saves the switch and routes atomically', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 3, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 3) + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, + dirty: false, + saving: false, + failed: false, + }) + }) + + it('starts an empty draft when a ready test scope has no decoded value', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: true, revision: 0, value: undefined }) + const face = controller.inject() + + face.toggleEnabled() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, dirty: true, invalid: true, + }) + }) + + it('keeps the Host value and reports a rejected write', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + const face = controller.inject() + + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true) + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, + dirty: true, + saving: false, + }) + }) + + it('loads stored routes, stages removal and disablement, and discards both', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().catalogPartial).toBe(true) + + face.toggleModel('missing') + expect(state().dirty).toBe(false) + face.toggleModel('alpha\0fast') + expect(state()).toMatchObject({ dirty: true, invalid: true }) + face.discard() + expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true }) + + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: true, enabled: false }) + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: false, enabled: true }) + }) + + it('retains selected routes when disabling and loads an already-ready enabled card', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() }) + + face.toggleEnabled() + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: false }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 5) + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, dirty: false, + }) + }) + + it('reports a directory error and retries it', async () => { + const host = stubSettingsScope() + const models = modelsApi({ error: 'offline' }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + + face.toggleEnabled() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') }) + face.retryCatalog() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) }) + }) + + it('rejects a draft after the Host revision changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: true, failed: false, dirty: true, + }) + face.save() + await Promise.resolve() + + expect(host.mutate).not.toHaveBeenCalled() + face.discard() + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, failed: false, dirty: false, enabled: true, + }) + }) + + it('settles a draft when a newer Host revision already contains it', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + }) + + it('retains unsaved routes across a catalog refresh', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, revision: 2, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const refreshed = deferred() + const models = vi.fn() + .mockResolvedValueOnce({ + ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + }, + }) + .mockImplementationOnce(() => refreshed.promise) + const controller = new SubagentModelSelectionCardController( + host.scope, { modelCatalog: models }, + ) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + face.toggleEnabled() + await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.refreshCatalog() + expect(state()).toMatchObject({ + catalogStatus: 'loading', + candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })], + }) + refreshed.resolve({ + ok: true, value: { groups: [], failures: [] }, + } as never) + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().candidates).toEqual([ + expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }), + ]) + + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 2) + }) + }) + + it('drops a draft when the connection generation changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.resetConnection() + host.publish({ + revision: 4, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + face.save() + await Promise.resolve() + expect(host.mutate).not.toHaveBeenCalled() + }) + + it('reloads the model catalog after invalidation', async () => { + const host = stubSettingsScope() + host.publish({ + status: 'ready', writable: true, revision: 1, + value: { enabled: true, allowedModels: [] }, user: {}, + }) + const models = vi.fn() + .mockResolvedValueOnce({ + ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + }, + }) + .mockResolvedValueOnce({ + ok: true, value: { + groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }], + failures: [], + }, + }) + const controller = new SubagentModelSelectionCardController( + host.scope, { modelCatalog: models }, + ) + const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) + + controller.refreshCatalog() + + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') }) + expect(models).toHaveBeenCalledTimes(2) + }) + + it('suppresses duplicate actions and late save settlements', async () => { + const host = stubSettingsScope() + const catalog = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const write = deferred() + const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => { + await write.promise + const enabled = ops.find(op => op.path[0] === 'enabled') + const allowedModels = ops.find(op => op.path[0] === 'allowedModels') + host.publish({ value: { + enabled: enabled?.op === 'set' ? enabled.value as boolean : false, + allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [], + } }) + }) + const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api) + const face = controller.inject() + + face.save() + face.toggleModel('alpha\0fast') + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + face.save() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') }) + face.save() + face.toggleModel('alpha\0fast') + face.save() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true) + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + face.discard() + controller.dispose() + write.resolve(undefined) + await write.promise + expect(mutate).toHaveBeenCalledOnce() + }) + + it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + + const pending = deferred() + const models = vi.fn(() => pending.promise) + const controller = new SubagentModelSelectionCardController(host.scope, { modelCatalog: models }) + const face = controller.inject() + face.toggleEnabled() + face.retryCatalog() + expect(models).toHaveBeenCalledOnce() + controller.dispose() + pending.reject(new Error('late failure')) + await pending.promise.catch(() => undefined) + + const pendingResolve = deferred() + const resolving = new SubagentModelSelectionCardController( + host.scope, + { modelCatalog: () => pendingResolve.promise }, + ) + const resolvingFace = resolving.inject() + resolvingFace.toggleEnabled() + resolving.dispose() + pendingResolve.resolve({ + ok: true, value: { groups: [], failures: [] }, + } as never) + await pendingResolve.promise + }) + + it('ignores writes while read-only and scope notifications after disposal', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} }) + const face = controller.inject() + + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + expect(host.mutate).not.toHaveBeenCalled() + + controller.dispose() + controller.refreshCatalog() + controller.resetConnection() + face.toggleEnabled() + face.retryCatalog() + face.save() + host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) + expect(host.mutate).not.toHaveBeenCalled() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + }) +}) + describe('WebSearchCardController', () => { it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 6184db0dff..9a230d0026 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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 packages/client/ui-settings/README.md -README.md: a9a45d90eaa46502829ee6d2c1793b7dadb1f0a5 -README.zh.md: bd615cbb5a3236370b7bbc9fc735deeab6efd8ce +README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 +README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index a9a45d90ea..3e4970bff9 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: one field path fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. +A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. ### Filling the settings slots @@ -55,7 +55,7 @@ The plugin injects `connection` and `remote` and owns the one `settings.describe ### Scope derivation -`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope with the namespace revision as `expectedRevision`; a committed write folds its answer in, a rejected or failed latest write triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. +`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. ### Schema service @@ -95,7 +95,6 @@ None; this package neither assembles nor sends a provider request. These limits define where the settings transport cannot reach; they are current package constraints. - **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API. -- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions. ### Dev Note diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index bd615cbb5a..a527dfe21a 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:单一字段路径以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 +功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 ### 填充设置 slot @@ -55,7 +55,7 @@ kind: "package-reference" ### Scope 派生 -`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入把应答折回镜像,被拒绝或失败的最新写入触发一次恢复读取,被取代的写入把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 +`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 ### Schema 服务 @@ -95,7 +95,6 @@ kind: "package-reference" 这些限制说明设置传输层够不到的地方;它们是当前包约束。 - **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。 -- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 ### 开发备注 diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 94d9e4c6d7..6c5bdc2be3 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 38f95177a7..dd06600781 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -2,6 +2,8 @@ * Settings-namespace scope contracts owned beside the settings transport. */ +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' + /** Client-side sync state of one settings namespace. */ export interface SettingsScopeSnapshot { /** @@ -46,7 +48,8 @@ export interface SettingsScopeSpec { /** * Reactive owner handle over one namespace's durable section — the browser * mirror of the Host-side `SettingsScope` owner seam. Domain services read - * and observe the snapshot and route explicit user choices through `set`. + * and observe the snapshot and route explicit user choices through its + * mutation methods. */ export interface SettingsScope { /** @returns the current sync snapshot (stable reference until the next change). */ @@ -57,6 +60,16 @@ export interface SettingsScope { * @returns the disposer removing this listener. */ subscribe(listener: () => void): () => void + /** + * Queue one atomic namespace mutation. All operations share one revision + * fence, Host validation, persistence decision, and recovery read. Supplying + * `expectedRevision` preserves an earlier read as the fence instead of using + * the latest queued or mirrored revision. + * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index d4ddd632e5..2d728e5154 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -104,7 +104,7 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { - return this.write({ op: 'set', path: [field], value: value as JsonValue }) + return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }]) } /** @@ -114,16 +114,23 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the clear and any latest-write recovery read. */ unset(field: string): Promise { - return this.write({ op: 'unset', path: [field] }) + return this.mutate([{ op: 'unset', path: [field] }]) } - private write(op: SettingsPathOpView): Promise { + /** + * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}. + * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise { + const ownedOps = structuredClone(ops) as SettingsPathOpView[] const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.pendingRevision ?? this.getSnapshot().revision + const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { - response = await this.api.settings.mutate(this.spec.namespace, [op], revision) + response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) } catch (_settingsWriteFailure) { await this.recover(generation) return diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index b6788f0dba..25091897f0 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { + JsonValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' @@ -174,6 +176,56 @@ describe('SettingsScopeController', () => { ) }) + it('sends one copied multi-field mutation behind one revision fence', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 7)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 8))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + const ops: SettingsPathOpView[] = [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ] + + const write = scope.mutate(ops) + ops[0] = { op: 'unset', path: ['enabled'] } + ;(ops[1] as unknown as { value: Array<{ model: string }> }).value[0]!.model = 'changed' + await write + + expect(mutate).toHaveBeenCalledWith( + 'ui-test', + [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], + 7, + ) + }) + + it('preserves an editor-owned revision fence behind earlier queued writes', async () => { + const first = deferred>() + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 7)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 8)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(rejected()) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + + const earlier = scope.set('preference', 'dark') + const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7) + first.resolve(ok(view({ preference: 'dark' }, 8))) + await Promise.all([earlier, fenced]) + + expect(mutate).toHaveBeenNthCalledWith( + 2, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'light' }], + 7, + ) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 }) + }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 50ccb7ed46..783a743df5 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 0e5bc155d8..acedab1603 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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 packages/client/ui-skill/README.md -README.md: 7d43f0a28010b68cf46b43376c36895e3d74f66b -README.zh.md: 846f1703ecf3460a370954655cee66a9c67acf21 +README.md: de564bd533f6cec83bb0a55e6ebfa687fa716ee9 +README.zh.md: 2408a29b83befd68785f7e7feccc588b6372a6fb diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 7d43f0a280..de564bd533 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-skill` lets users invoke skills by typing `/name` in the composer: the suggestion menu offers user-invocable skills from the `skill.list` RPC, and a pick lands the literal `/name ` text that the host then loads as the skill's instructions. Loading is deterministic: the host's pre-step boundary (`dsh-tool-skill`) recognizes the whitespace-bounded `/name` token in the sent message and injects the rendered `` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. Settled skill calls render in the conversation as an expandable `Instructions` card, derived only from the frozen call/result slice. +`dsh-client-ui-skill` lets users invoke skills by typing `/name` in the composer: the suggestion menu offers user-invocable skills from the `skills/list` Remote, and a pick lands the literal `/name ` text that the host then loads as the skill's instructions. Loading is deterministic: the host's pre-step boundary (`dsh-tool-skill`) recognizes the whitespace-bounded `/name` token in the sent message and injects the rendered `` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. Settled skill calls render in the conversation as an expandable `Instructions` card, derived only from the frozen call/result slice. ## Table of Contents @@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name` ### What the source offers -Ordinary-session candidates come from the `skill.list` RPC; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skill.list` is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. +Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. ### The skill tool row diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 846f1703ec..2408a29b83 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-skill` 让用户通过在编辑器中键入 `/name` 来调用 skill:建议菜单从 `skill.list` RPC 提供用户可调用的 skill 候选,选择一项会落下字面文本 `/name `,宿主随后将其加载为 skill 的指令。加载是确定性的:宿主的 pre-step 边界(`dsh-tool-skill`)识别发出消息中以空白为界的 `/name` token,并为每个入口注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。已结算的 skill 调用在对话中渲染为可展开的 `Instructions` 卡片,只从冻结的调用/结果切片派生。 +`dsh-client-ui-skill` 让用户通过在编辑器中键入 `/name` 来调用 skill:建议菜单从 `skills/list` Remote 提供用户可调用的 skill 候选,选择一项会落下字面文本 `/name `,宿主随后将其加载为 skill 的指令。加载是确定性的:宿主的 pre-step 边界(`dsh-tool-skill`)识别发出消息中以空白为界的 `/name` token,并为每个入口注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。已结算的 skill 调用在对话中渲染为可展开的 `Instructions` 卡片,只从冻结的调用/结果切片派生。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### source 提供什么 -普通会话的候选来自 `skill.list` RPC;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skill.list` 失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 +普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 ### skill 工具行 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d90bd9fbb2..d6e679509b 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9b66956c2d..36d1e9778c 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -1,6 +1,6 @@ /** * Skill reference plugin, browser half: registers the '/' skill source — - * candidates from the skill.list RPC addressed by the per-call session + * candidates from the `skills/list` Remote addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host * resolves cwd from the session header). A pick lands the literal `/name ` * text and the prompt ships the same literal (plain-text-reference decision; @@ -10,7 +10,7 @@ * leading `/name` naming a user-invocable skill and injects the rendered * body for every entry point, including `disable-model-invocation` skills the * model-side catalog never lists (issue #1470). The RPC rides the plugin's - * root-context connection captured at registration — the source never reads + * root-context Remote captured at registration — the source never reads * services off a per-call argument. Draft chip visuals derive from * the lexicon scan; this source implements no reference codec. * @@ -31,7 +31,7 @@ */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' +import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -58,7 +58,7 @@ interface CatalogFetch { } /** Required services: reference source faces plus the tool-row and locale registries. */ -export const inject = ['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote'] +export const inject = ['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills'] /** * Client plugin body: register the '/' source, dictionaries, and keyed tool row. @@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void { SkillRow, )) - const skills = (ctx.get('connection') as ConnectionHandle).api.skills + const skills = ctx.remote.skills const sessions = ctx.sessions // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. @@ -98,8 +98,8 @@ export function apply(ctx: ClientContext): void { if (existing !== undefined) return existing.promise const abort = new AbortController() const promise = (async () => { - const { result } = await skills.list({ sessionId }, abort.signal) - if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`) + const result = await skills.list({ sessionId }, abort.signal) + if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`) return result.value.skills })() const entry: CatalogFetch = { promise, abort } diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 2f59d19c42..89111675f3 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -27,11 +27,7 @@ type SkillRow = { name: string; description: string; whenToUse?: string; modelIn type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } -type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> -type InvokeResult = - | { ok: true; value: { accepted: true } } - | { ok: false; error: { code: string; message: string; details: object } } -type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> +type ListFn = (payload: object, signal?: AbortSignal) => Promise interface PresentationCapture { slots: SlotRegistry @@ -63,18 +59,17 @@ function providePresentation(ctx: Context): PresentationCapture { } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { +async function bench(list: ListFn, addressed?: SessionId) { const ctx = new Context() let captured: InputTriggerSource | undefined ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } }) - const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) - ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) + ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - const remote = new TestRemote(ctx) + const remote = new TestRemote(ctx, { skills: { list } }) providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, source: captured!, remote } @@ -86,7 +81,7 @@ const CATALOG: SkillRow[] = [ { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] -const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) +const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ ok: true as const, value: { skills } }) /** Counting fake: records payloads, resolves the shared catalog. */ function countingList(skills: SkillRow[] = CATALOG) { @@ -103,19 +98,19 @@ const sid = (id: string) => id as SessionId const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) }) const req = (query: string, signal?: AbortSignal) => - ({ query, position: 'leading' as const, signal: signal ?? new AbortController().signal }) + ({ query, position: 'leading' as const, drilled: false, signal: signal ?? new AbortController().signal }) describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote']) + expect(inject).toEqual(['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills']) }) it('registers the dedicated skill row and its locale dictionaries', async () => { const ctx = new Context() ctx.provide('inputTriggers', { registerSource: () => () => {} }) - ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: () => undefined }) - new TestRemote(ctx) + new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() const entry = presentation.slots.entries('tool.call.toolview')[0] @@ -151,8 +146,8 @@ describe('apply', () => { // InputTriggerService itself injects 'sessions'; the stub unblocks its fiber. ctx.provide('sessions', {}) await ctx.plugin(InputTriggerService).await() - ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) - new TestRemote(ctx) + ctx.provide('connection', {}) + new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -188,10 +183,10 @@ describe('candidates: sessionId addressing', () => { it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { const { source } = await bench(() => Promise.resolve({ - result: { ok: false, error: { code: 'internal', message: 'boom', details: {} } }, + ok: false, error: { code: 'internal', message: 'boom', details: {} }, })) await expect(source.candidates(proj('s1'), req('co'))) - .rejects.toThrow('skill.list failed: internal: boom') + .rejects.toThrow('skills/list failed: internal: boom') }) it('does not fetch Agent-bound skills for an addressed child', async () => { @@ -248,7 +243,7 @@ describe('catalog cache', () => { const { source } = await bench((payload) => { payloads.push(payload) return fail - ? Promise.resolve({ result: { ok: false as const, error: { code: 'internal', message: 'boom', details: {} } } }) + ? Promise.resolve({ ok: false as const, error: { code: 'internal', message: 'boom', details: {} } }) : listOk(CATALOG)(payload) }) await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('boom') diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index d93b9addc9..0e317a33ee 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 5aeeb4189b..75afb63a17 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4bb37da9e4..ee343d5a89 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ede6905289..5cfd56493f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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 packages/client/ui-tool/README.md -README.md: 4a5c240cfcb6bad1b4b1c8917dcdbc54fd0ce7b9 -README.zh.md: b243b18cb969cdc1c505884ba8bbfbbd7bc3be41 +README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100 +README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 4a5c240cfc..773a93801e 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### Built-in views -This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. Unsupported or malformed inputs fall back to flattened Tool result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. @@ -96,7 +96,7 @@ None; this package neither assembles nor sends a provider request. These limits define the dispatch depth and the view ownership; they are current package constraints. -- **The Host excludes `run_code` from Code Mode program bindings** — production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. +- **The Host excludes `run_code` from PTC mode program bindings** — production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. - **First-party Tool views are colocated here** — they can move to their owning business packages independently through the keyed slot. - **Tool copy reuses the `ui-conversation` locale namespace** — tool titles, row chrome, and Cordis-free primitive labels use that dictionary; presenter models retain locale keys or data rather than rendered wording. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index b243b18cb9..88df08d7b5 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 -本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。不受支持或格式错误的输入回退为压平的工具结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 与 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 @@ -96,7 +96,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` 这些限制定义分派深度与视图归属;它们是当前包约束。 -- **Host 不把 `run_code` 暴露为 Code Mode 程序 binding**:生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 +- **Host 不把 `run_code` 暴露为 PTC mode 程序 binding**:生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 - **第一方工具视图集中在本包**:它们可以通过 keyed slot 独立迁移到各自所属的业务包。 - **工具文案复用 `ui-conversation` locale namespace**:工具标题、行 chrome 与无 Cordis 的 primitive label 使用该字典;presenter model 保留 locale key 或数据,而不是已渲染文案。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 0e38fce093..ca96120343 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index dce00a0f54..385dc58edf 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -24,7 +24,7 @@ export const inject = ['slots', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) + const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index c9206c8cd9..ce39262499 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,5 @@ /** Tool UI slot declarations and their composed component props. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -47,10 +47,10 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostDescriptionInjected = { +export type ToolConnectionGenerationInjected = { hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current Connection generation, bound by the slot renderer. */ + connectionGeneration: ConnectionGenerationState } } @@ -58,9 +58,9 @@ export type ToolHostDescriptionInjected = { export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 2079d09a96..e27656cebb 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index ebb4578281..6a15997531 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t, }: ToolTreeProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const block = node.data.root return ( ) { - const home = useHostDescription(description => description?.home) + block, cwd, useConnectionGeneration, t, +}: Pick) { + const home = useConnectionGeneration(generation => generation?.host.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) diff --git a/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css new file mode 100644 index 0000000000..1c84154c42 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css @@ -0,0 +1,67 @@ +.card { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 360px; + margin: 4px 0 4px 4px; + padding: 16px 20px; + overflow-y: auto; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-bg-base); +} + +.item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.question, +.answer { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.question { + color: var(--dsw-alias-label-tertiary); +} + +.answer { + color: var(--dsw-alias-label-primary); +} + +.answerLine { + display: block; +} + +.skipped { + color: var(--dsw-alias-label-tertiary); +} + +.verdict { + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.questionList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding-left: 20px; +} + +.unansweredQuestion { + color: var(--dsw-alias-label-tertiary); + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} diff --git a/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx new file mode 100644 index 0000000000..d704aea501 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx @@ -0,0 +1,40 @@ +/** Ask-user transcript rendering from validated plain card data. @module */ + +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' +import css from './AskQuestionCard.module.css' + +/** + * Render a validated ask-user transcript from plain card data. + * @param props - Localized transcript card data. + * @returns the readable answered or unanswered question list. + */ +export function AskQuestionCard({ card }: { card: AskQuestionCardModel }) { + if (card.kind === 'unanswered') { + return ( +
+

{card.verdict}

+
    + {card.questions.map(question => ( +
  • {question.question}
  • + ))} +
+
+ ) + } + return ( +
+ {card.questions.map(question => ( +
+
{question.question}
+
+ {question.answers.length === 0 + ? {card.skippedLabel} + : question.answers.map((answer, index) => ( + {answer} + ))} +
+
+ ))} +
+ ) +} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 8102965a6d..ccf211e468 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -13,8 +13,10 @@ import { import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' +import { AskQuestionCard } from './AskQuestionCard.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -37,6 +39,8 @@ export interface ToolRowProps { body: string | null /** Flattened result text for the expanded Output section; null/absent = no output section. */ output?: string | null | undefined + /** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */ + askQuestion?: AskQuestionCardModel | null | undefined /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ @@ -91,6 +95,7 @@ export function ToolRow({ summarySuffix, body, output, + askQuestion, errorSummary, terminal, diff, @@ -115,8 +120,9 @@ export function ToolRow({ const readBody = read ?? null const searchBody = search ?? null const webBody = web ?? null + const askQuestionBody = askQuestion ?? null const outputText = output ?? null - const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -183,67 +189,69 @@ export function ToolRow({ )} >
- {terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : searchBody !== null - ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - - ) - : webBody !== null - ? - : ( + {askQuestionBody !== null + ? + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && (