Merge commit 'dae2c77f920' into turtle/async-notice-efficiency
This commit is contained in:
commit
f7a0d4a8c9
199 files changed
+5763
-1423
No files matched your search
+2
-2
@@ -18,5 +18,5 @@
|
||||
en: fa503dcfaa6f438f
|
||||
zh: 26052212a124e133
|
||||
/agent-note-compiler-independent-typert-type-model/consequences:
|
||||
en: 4a472acf377f8361
|
||||
zh: cb83602b123a4aae
|
||||
en: e1022cf9523fb28e
|
||||
zh: 01dfb764fb98de0d
|
||||
@@ -51,3 +51,5 @@ For each supported node kind and literal category, Zod emitter tests run both su
|
||||
New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
|
||||
|
||||
Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them.
|
||||
|
||||
Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity.
|
||||
+2
@@ -51,3 +51,5 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars
|
||||
新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
|
||||
|
||||
包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
|
||||
普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。
|
||||
+10
-10
@@ -12,14 +12,14 @@
|
||||
en: 44d946b53496974c
|
||||
zh: 403447c25a5b2d7c
|
||||
/agent-note-typert-gateway-targeted-method-calls/components-and-cordis-services:
|
||||
en: aa7f509189ade32e
|
||||
zh: ef2841bc793f31b2
|
||||
en: 02c20706e48467e6
|
||||
zh: 2b0916ae0cee351b
|
||||
/agent-note-typert-gateway-targeted-method-calls/business-declarations:
|
||||
en: c6b70d442b137d2e
|
||||
zh: 6d0dbefe89937ef1
|
||||
en: b9394d5ef98cc82a
|
||||
zh: 1fce07fe150722ee
|
||||
/agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet:
|
||||
en: 155afbe4b8af600b
|
||||
zh: 14705698a9cdbd84
|
||||
en: 161dccf2b4cf565d
|
||||
zh: fa587fe0b46bf551
|
||||
/agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration:
|
||||
en: 9f7448898cf5cadc
|
||||
zh: 419573c589ed6f7d
|
||||
@@ -48,8 +48,8 @@
|
||||
en: 5d14e1b7afc53efb
|
||||
zh: e2f4b5814083738f
|
||||
/agent-note-typert-gateway-targeted-method-calls/src-and-lib-operating-modes:
|
||||
en: 1e366dcfab74831b
|
||||
zh: 14199f25f20a9352
|
||||
en: f1b8426d1cfaed35
|
||||
zh: a370376923c98d6e
|
||||
/agent-note-typert-gateway-targeted-method-calls/host-gateway-resolution:
|
||||
en: 88a95d7ab4621caf
|
||||
zh: 89b63fb6440fca79
|
||||
@@ -66,8 +66,8 @@
|
||||
en: b1c0c3dd77c56ce8
|
||||
zh: 769ffd4aeefd949c
|
||||
/agent-note-typert-gateway-targeted-method-calls/alternatives-considered:
|
||||
en: 0d68c499b7780f4f
|
||||
zh: bdd1e57232b996e2
|
||||
en: eaa3ffe5ca14005c
|
||||
zh: 1f0b455e30ade872
|
||||
/agent-note-typert-gateway-targeted-method-calls/verification:
|
||||
en: 7433bdb522bb2083
|
||||
zh: 545c17613b636f00
|
||||
|
||||
@@ -28,7 +28,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
|
||||
|
||||
| Component | Cordis service | Responsibility |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser |
|
||||
| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, the Remote map, and Zod-backed lazy info assembly; no dependency on the compiler, Connection, or Browser |
|
||||
| Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
|
||||
| Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
|
||||
| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments |
|
||||
@@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService {
|
||||
|
||||
An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. Typert does not infer either mode from the method body or from a missing parameter.
|
||||
|
||||
Business packages depend only on the lightweight `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime.
|
||||
Business packages depend only on `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, HTTP, or the Client runtime. Its pure `./remote-info` entry uses Zod to assemble lazy named-input schemas without importing Cordis; the root entry also re-exports it alongside Cordis-backed declarations.
|
||||
|
||||
A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal.
|
||||
|
||||
## Decorators and the explicit Gateway facet
|
||||
|
||||
A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance.
|
||||
A decorator states Remote participation, an optional export name, and stream delivery; `@Remote` options contain only `name` and `mode`. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance.
|
||||
|
||||
In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function.
|
||||
|
||||
Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete consumer adaptation.
|
||||
|
||||
In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata.
|
||||
|
||||
## Lookup and Remote Scope registration
|
||||
@@ -349,9 +351,9 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir
|
||||
|
||||
## SRC and LIB operating modes
|
||||
|
||||
SRC supports local source startup. The versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
SRC supports local source startup. For methods without registered strict definitions, the versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
|
||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. The conservative fallback does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||
|
||||
A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types.
|
||||
|
||||
@@ -474,7 +476,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h
|
||||
|
||||
**Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so Typert generates strict information at compile time.
|
||||
|
||||
**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass.
|
||||
**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. Source startup can load ordinary prebuilt artifacts or use conservative markers without loading a TypeScript Program.
|
||||
|
||||
**Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
|
||||
|
||||
| 组件 | Cordis 服务 | 职责 |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser |
|
||||
| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context、Remote map 和基于 Zod 的惰性 info 组装;不依赖 compiler、Connection 或 Browser |
|
||||
| Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
|
||||
| Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
|
||||
| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 |
|
||||
@@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService {
|
||||
|
||||
同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 Typert 根据方法体或参数缺失自动猜测。
|
||||
|
||||
业务包只依赖轻量的 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。
|
||||
业务包只依赖 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、HTTP 或 Client runtime。其纯 `./remote-info` 入口使用 Zod 组装惰性具名输入 schema,不导入 Cordis;根入口在提供依赖 Cordis 的声明之余,也重新导出该能力。
|
||||
|
||||
支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。
|
||||
|
||||
## Decorator 与显式 Gateway facet
|
||||
|
||||
Decorator 只表达“该方法参与 Remote 约定”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。
|
||||
Decorator 表达 Remote 参与、可选导出名称和流式投递;`@Remote` 选项仅包含 `name` 和 `mode`。它不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。
|
||||
|
||||
SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。
|
||||
|
||||
普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的消费方适配。
|
||||
|
||||
LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。
|
||||
|
||||
## Lookup 与 Remote Scope 注册
|
||||
@@ -349,9 +351,9 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完
|
||||
|
||||
## SRC 与 LIB 运行模式
|
||||
|
||||
SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
SRC 面向本地源码启动。对于没有已注册严格定义的方法,`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
|
||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。保守回退不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||
|
||||
SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。
|
||||
|
||||
@@ -474,7 +476,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
|
||||
**让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 Typert compiler 生成。
|
||||
|
||||
**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB 约定 pass。
|
||||
**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。源码启动可加载普通预构建产物或使用保守标记,而无需加载 TypeScript Program。
|
||||
|
||||
**手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。
|
||||
|
||||
|
||||
+10
-10
@@ -15,8 +15,8 @@
|
||||
en: adccc5dc758ad8f6
|
||||
zh: 50304d68db42e8b1
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/ownership:
|
||||
en: 0543678ad9d26fc5
|
||||
zh: aa6ba207e661c567
|
||||
en: a28e37fda6e9eaff
|
||||
zh: 9ca622e09ab24b4d
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime:
|
||||
en: 447713bbdf1ab4d8
|
||||
zh: 2757678ae47822aa
|
||||
@@ -30,14 +30,14 @@
|
||||
en: 3a33f2583e30e32a
|
||||
zh: 0ad7e8d1e47dc510
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping:
|
||||
en: 10323b0e0340a40b
|
||||
zh: a17fc3a4d4d820a7
|
||||
en: d87d7432c37405a7
|
||||
zh: 49f1bbf53856851a
|
||||
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
|
||||
en: bf61d0ec80c63bb4
|
||||
zh: d4ce4d4438ffb013
|
||||
en: 1a070542bfa40e68
|
||||
zh: 82e32964da912dd8
|
||||
/agent-note-timed-user-questions-as-two-settlements/verification:
|
||||
en: d912a73a5ff409eb
|
||||
zh: 7b626794773e2301
|
||||
en: 1d19d196c363a273
|
||||
zh: 7cb0475f0cb554d1
|
||||
/agent-note-timed-user-questions-as-two-settlements/consequences:
|
||||
en: 2bc99c99be8ca9da
|
||||
zh: 8f262ec8b9b0945e
|
||||
en: 0580e85cc12439c5
|
||||
zh: a4b151d2c9e5b747
|
||||
+5
-6
@@ -29,7 +29,7 @@ Foreground answers use the existing Remote Event waterfall. A timeout ends that
|
||||
| `UserQuestionService` | Foreground waits and Client claims, unattended deadlines, late-answer validation and steering, and the durable question projection. |
|
||||
| Gateway and Remote runtime | Existing request delivery, settlement, cancellation, and Remote stream transport; no question-specific timer or event-name branch. |
|
||||
| `ui-user-questions` | One card per Session and call, answer-channel selection, local countdown and drafts, and the reply Definition and renderer. |
|
||||
| `ui-chat` | Message-id-based presentation aggregation and ordinary process/Turn folding. |
|
||||
| `ui-chat` | Ordinary process/Turn folding. |
|
||||
| Question-specific tool row | Pending/answered presentation and panel actions, composed in `QuestionToolRow` without adding `rowAction` to generic `ToolRow`. |
|
||||
|
||||
The [question service](../../../../packages/interaction/user-questions/README.md), [tool](../../../../packages/interaction/tool-ask-user/README.md), and [questions UI](../../../../packages/client/ui-user-questions/README.md) own their package contracts.
|
||||
@@ -72,11 +72,11 @@ Autofocus applies only when the answer channel is ready, no countdown is active,
|
||||
|
||||
The questions UI identifies a late reply from `source.kind`, then parses its recorded JSON for question-and-answer presentation. Pasting identical JSON in an ordinary user message does not identify it as a reply. Unreadable recorded payloads retain their raw-text presentation.
|
||||
|
||||
The ordinary message Definition and the question-reply Definition both match the appended message and retain separate Node Store projections with the same message id. Chat business grouping omits the `turn-trigger` presentation when a `question-reply` with that id is present. The duplicate trigger neither renders nor splits a group; the reply renders once. The same aggregation applies to unscoped historical input.
|
||||
The question-reply Definition claims the appended reply message on the Chat target through an [exclusive match](2026-10-12-exclusive-conversation-matches.md), so the ordinary message Definition builds no `turn-trigger` or context projection for it and the reply renders once, including for unscoped historical input.
|
||||
|
||||
The reply participates in ordinary process grouping and whole-Turn folding. It is not independently visible: reading it can require expanding both its Turn and process group. Turn disclosure stays before the group it controls, including a reply that opens the Turn.
|
||||
|
||||
This decision adds no process-role field, independent-layout flag, user-message structure change, or target-local fallback dispatch. The independent-kind rules stay in Chat's existing grouping logic. A node-data-owned declaration of grouping and overlap semantics is deferred infrastructure, not part of the question feature.
|
||||
This decision adds no independent-layout flag or user-message structure change. The reply declares no group-tree position, so it follows the default placement of process content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -90,7 +90,7 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
|
||||
**Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation.
|
||||
|
||||
**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding and Chat-owned aggregation are sufficient for this decision; independently declared node-data grouping semantics remain a separate design task.
|
||||
**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding is sufficient for this decision; placing the reply as user input through its group-tree position is separate work.
|
||||
|
||||
**A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable.
|
||||
|
||||
@@ -101,7 +101,7 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
- [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
|
||||
- [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement.
|
||||
- [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions.
|
||||
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
|
||||
|
||||
## Consequences
|
||||
@@ -111,7 +111,6 @@ The model can continue independent work while the question remains answerable. B
|
||||
- Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold.
|
||||
- A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure.
|
||||
- Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start.
|
||||
- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim.
|
||||
- Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee.
|
||||
- A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer.
|
||||
- A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery.
|
||||
+5
-6
@@ -29,7 +29,7 @@ Status: implemented
|
||||
| `UserQuestionService` | 前台等待与 Client 接手、无人接手时的 deadline、迟到回答校验与 steering(中途引导),以及持久问题投影。 |
|
||||
| Gateway 与 Remote 运行时 | 现有请求投递、结算、取消及 Remote stream 传输;不包含问题专属计时器或按事件名分支。 |
|
||||
| `ui-user-questions` | 每个 Session 和调用的一张卡片、回答通道选择、本地倒计时与草稿,以及回复 Definition 和渲染器。 |
|
||||
| `ui-chat` | 基于消息 id 的呈现聚合,以及普通过程/轮次折叠。 |
|
||||
| `ui-chat` | 普通过程/轮次折叠。 |
|
||||
| 问题专用工具行 | pending/已回答呈现与面板操作,在 `QuestionToolRow` 中组合,不向通用 `ToolRow` 添加 `rowAction`。 |
|
||||
|
||||
[问题服务](../../../../packages/interaction/user-questions/README.zh.md)、[工具](../../../../packages/interaction/tool-ask-user/README.zh.md)和[问题 UI](../../../../packages/client/ui-user-questions/README.zh.md)分别拥有各包的约定。
|
||||
@@ -72,11 +72,11 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
问题 UI 通过 `source.kind` 识别迟到回复,再解析已记录的 JSON 来呈现问答。普通用户消息粘贴相同 JSON 不会被识别为回复。无法读取的已记录载荷保留原始文本呈现。
|
||||
|
||||
普通消息 Definition 和问答回复 Definition 都匹配追加的消息,在 Node Store 中保留消息 id 相同的独立投影。存在同 id 的 `question-reply` 时,Chat 业务分组省略 `turn-trigger` 呈现。重复触发节点既不渲染,也不切开分组,回复只显示一次。相同聚合也适用于无轮次归属的历史输入。
|
||||
问答回复 Definition 通过[独占匹配](2026-10-12-exclusive-conversation-matches.zh.md)在 Chat target 上认领追加的回复消息,普通消息 Definition 因此不再为它建立 `turn-trigger` 或 context 投影,回复只显示一次;无轮次归属的历史输入同样如此。
|
||||
|
||||
回复参与普通过程分组和整轮折叠,不保证独立可见:阅读时可能需要展开所属轮次与过程分组。轮次折叠控件始终在所控制的分组之前,包括由回复开启轮次的情况。
|
||||
|
||||
本决策不添加 process-role 字段、独立布局标记、用户消息结构变更或目标内 fallback 派发。独立 kind 规则保留在 Chat 现有分组逻辑中。由节点 data 声明分组与重叠语义的机制是留待拆分的基础设施,不属于问题功能。
|
||||
本决策不添加独立布局标记或用户消息结构变更。回复不声明分组树位置,因此按过程内容的默认位置放置。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -90,7 +90,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
**在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。
|
||||
|
||||
**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠与 Chat 内聚合足以满足本决策;由节点 data 独立声明分组语义仍是单独的设计任务。
|
||||
**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠足以满足本决策;通过分组树位置把回复放置为用户输入是单独的工作。
|
||||
|
||||
**面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。
|
||||
|
||||
@@ -101,7 +101,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
- [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
|
||||
- [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。
|
||||
- [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。
|
||||
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
|
||||
|
||||
## Consequences
|
||||
@@ -111,7 +111,6 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
- 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。
|
||||
- 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。
|
||||
- Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。
|
||||
- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。
|
||||
- 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。
|
||||
- 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。
|
||||
- PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。
|
||||
+8
-8
@@ -9,17 +9,17 @@
|
||||
en: 84858bc0e1e268ab
|
||||
zh: 39ebb2979968d6cc
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/decision:
|
||||
en: c8c4091863dec269
|
||||
zh: 57a0f26683867350
|
||||
en: ca3153ae96994baa
|
||||
zh: de6c7d925559efbb
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice:
|
||||
en: db8d77cd773ad646
|
||||
zh: ab2f03b8fc58e952
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
|
||||
en: 635ddcb04fefe8e2
|
||||
zh: 360c171ccb613ed5
|
||||
en: a2b48738666a7d5f
|
||||
zh: 53377f4f1109af26
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
|
||||
en: c8cfdc3009b19b12
|
||||
zh: 36c541cd9692057d
|
||||
en: f51678f9f30bad59
|
||||
zh: b525e9ed10b9928c
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/testing:
|
||||
en: 0d7f5281e66b0c39
|
||||
zh: 5be221de46e9e0f7
|
||||
en: e2e3982718e16ef4
|
||||
zh: 4f3802946e51c55c
|
||||
@@ -12,8 +12,9 @@ On next message folding kept only the last Turn of the loaded timeline expanded.
|
||||
|
||||
A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange.
|
||||
|
||||
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means.
|
||||
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position.
|
||||
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`.
|
||||
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange.
|
||||
- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive.
|
||||
- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange".
|
||||
|
||||
As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path.
|
||||
@@ -34,11 +35,14 @@ The host claims the waking input right after `turn/start`, assembles the system
|
||||
|
||||
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab.
|
||||
|
||||
**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work.
|
||||
**Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position.
|
||||
|
||||
**Derive the exchange from an `input` in Step 1.** A trigger notice is its Turn's opening input but must not open an exchange. Deriving the exchange would drop trigger notices from `input` and change how notice-woken Turns order their process rows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds.
|
||||
- Notice-woken Turns no longer cause a fold, reserved room, or blank space.
|
||||
- External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them.
|
||||
- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again.
|
||||
- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange.
|
||||
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here.
|
||||
@@ -47,5 +51,6 @@ The host claims the waking input right after `turn/start`, assembles the system
|
||||
|
||||
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window.
|
||||
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope.
|
||||
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer.
|
||||
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer, with non-opening inputs at `input`.
|
||||
- `packages/client/ui-chat/tests/process-groups.client.spec.ts`: a Node at any position splits the step run regardless of its kind, and a position change regroups.
|
||||
- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply.
|
||||
@@ -12,8 +12,9 @@ Status: implemented
|
||||
|
||||
通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。
|
||||
|
||||
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。
|
||||
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。
|
||||
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。
|
||||
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。
|
||||
- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。
|
||||
- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。
|
||||
|
||||
结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。
|
||||
@@ -34,11 +35,14 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
|
||||
|
||||
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。
|
||||
|
||||
**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。
|
||||
**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。
|
||||
|
||||
**由 Step 1 中的 `input` 推导交互段。** 触发通知是所在 Turn 的开场输入,却不能开启交互段。推导交互段就得把触发通知移出 `input`,从而改变通知唤醒的 Turn 中过程行的排列。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。
|
||||
- 通知唤醒的 Turn 不再产生折叠、补白和空白。
|
||||
- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。
|
||||
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
|
||||
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
|
||||
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。
|
||||
@@ -47,5 +51,6 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
|
||||
|
||||
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。
|
||||
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。
|
||||
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。
|
||||
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer,非开启输入位于 `input`。
|
||||
- `packages/client/ui-chat/tests/process-groups.client.spec.ts`:任意位置上的节点不论 kind 都会切开步骤组,位置变化会触发重新分组。
|
||||
- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-12-exclusive-conversation-matches.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md
|
||||
/agent-note-exclusive-conversation-matches:
|
||||
en: 6f78605cc85cf7f2
|
||||
zh: f73e26bb3f9d1896
|
||||
/agent-note-exclusive-conversation-matches/problem:
|
||||
en: 38ed7541d4cb7019
|
||||
zh: 98ed08f8c0c7c5ba
|
||||
/agent-note-exclusive-conversation-matches/decision:
|
||||
en: f2bfa3d0cc6b429a
|
||||
zh: c1dd501c79086d71
|
||||
/agent-note-exclusive-conversation-matches/alternatives-considered:
|
||||
en: efd209a2c0e97381
|
||||
zh: 8b6f10ff4de49449
|
||||
/agent-note-exclusive-conversation-matches/consequences:
|
||||
en: 414ae7349bd4cc03
|
||||
zh: 9861b3e72bdfc548
|
||||
/agent-note-exclusive-conversation-matches/testing:
|
||||
en: a88ed18065da4d28
|
||||
zh: 5bf4448a8b1f4763
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Exclusive Conversation matches
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-12-exclusive-conversation-matches.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target.
|
||||
|
||||
The late question reply is the shipped case. Chat's generic `input-message` Definition matches every appended `user/message`, and the questions UI's `question-reply` Definition matches the reply message it presents. Chat process grouping therefore dropped the duplicate `turn-trigger` presentation by message id, in both the Turn-owned and unscoped paths, and anchored the Turn control to the first group member so it stayed above the reply. The duplicate Node remained in the Node Store, and the generic Definition kept treating the reply as context or a trigger notice.
|
||||
|
||||
## Decision
|
||||
|
||||
A match result may claim its event on the Definition's target:
|
||||
|
||||
```ts
|
||||
interface ConversationMatchResult {
|
||||
readonly id: string
|
||||
readonly role: 'start' | 'update'
|
||||
readonly exclusive?: true
|
||||
}
|
||||
```
|
||||
|
||||
- **Dispatch.** `ConversationNodeAssembler.dispatchInput()` evaluates every match of one event into two reused arrays before accepting any, then settles claims. Other Definitions of a claimed target build no Context for the event, and that target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. The outcome is independent of registration order. Live append and window collection share this dispatch.
|
||||
- **Failures.** Two claims on one target, or a claim from a Definition without a target, throw during dispatch and name the event, its `seq`, and the claiming Definition kinds.
|
||||
- **Scope.** A claim belongs to one event and comes from `match()`, so a Definition claims only the events it recognizes. The generic Definition declares nothing.
|
||||
- **Current use.** `question-reply` returns `exclusive: true` for a recorded `user-question-reply` message. Chat no longer aggregates by message id, and the reply's placement is unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Definition precedence (`specific` / `default`).** A generic Definition would declare itself overridable, and dispatch would become a priority order rather than a statement of which Definition presents an event. A claim needs no change to the generic Definition, and two claims fail instead of silently ranking.
|
||||
|
||||
**Declare human sources in a registry and render the reply through user-text rules.** The reply would stay a generic message Node with customized text. Chat's message and Inbox Definitions would need a service-backed predicate, registry changes would have to reassemble every bound Conversation, and the text rule would need the message source as a fact. A claim keeps the reply in its owning Definition.
|
||||
|
||||
**Mark human-authored sources in durable data.** A generic field on the message source would let the generic Definition classify the reply, but it changes a persisted type, and replies recorded by released builds do not carry it.
|
||||
|
||||
**Skip the reply kind in `input-message`.** The generic Chat Definition would name a plugin's source kind.
|
||||
|
||||
**Deduplicate in the grouping output.** This is the replaced mechanism: the duplicate Node stays stored and visible to every other Chat reader, and each grouping path repeats the aggregation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A claimed event has exactly one Context per claimed target, so no hidden duplicate remains in the Node Store.
|
||||
- A conflicting claim fails at dispatch rather than at registration, because claims are decided per event from event data.
|
||||
- Dispatch makes one extra pass over an event's matches, usually one to three, and allocates nothing for events without a claim.
|
||||
- A plugin that presents part of a generic Definition's event family on one target uses a claim; it does not need the generic Definition's cooperation.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts` ("exclusive matches"): a claim withholds the event from other Definitions of its target in either registration order and on live append; other targets, target-less Definitions, and no fallback; two claims and a target-less claim throw.
|
||||
- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`: the reply match claims its event, and an assembler holding both message Definitions produces one Chat Node for the reply.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Conversation 独占匹配
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-12-exclusive-conversation-matches.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
每个事件 Definition 各自独立地匹配每个事件,同一个 target 上可能有一个呈现整类事件的通用 Definition。业务 Definition 自己呈现这类事件中的一种时,两个 Definition 会命中同一个事件,各自建立 Context 和 Node。业务 Definition 没有办法声明这个事件在该 target 上归它呈现。
|
||||
|
||||
迟到的问题回答是已上线的实例。Chat 的通用 `input-message` Definition 匹配每条追加的 `user/message`,问题 UI 的 `question-reply` Definition 也匹配它要呈现的回答消息。Chat 过程分组因此在有轮次归属和无轮次归属两条路径上都按消息 id 丢掉重复的 `turn-trigger` 呈现,并把整轮控件锚到组首成员,让它留在回答之上。重复的 Node 仍留在 Node Store 中,通用 Definition 也仍把回答当作 context 或触发通知。
|
||||
|
||||
## Decision
|
||||
|
||||
match 结果可以在 Definition 的 target 上认领它的事件:
|
||||
|
||||
```ts
|
||||
interface ConversationMatchResult {
|
||||
readonly id: string
|
||||
readonly role: 'start' | 'update'
|
||||
readonly exclusive?: true
|
||||
}
|
||||
```
|
||||
|
||||
- **派发。** `ConversationNodeAssembler.dispatchInput()` 先把一个事件的全部 match 求入两个复用数组,再结算认领,之后才逐个接收。被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。结果与注册顺序无关。实时追加和窗口收集共用这一派发。
|
||||
- **失败。** 同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在派发时抛错,错误信息包含事件、其 `seq` 与认领方 Definition 的 kind。
|
||||
- **范围。** 认领只对一个事件生效,由 `match()` 返回,因此 Definition 只认领它识别出的事件;通用 Definition 不需要声明任何东西。
|
||||
- **当前用法。** `question-reply` 对已记录的 `user-question-reply` 消息返回 `exclusive: true`。Chat 不再按消息 id 聚合,回答的放置不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Definition 优先级(`specific` / `default`)。** 通用 Definition 得声明自己可被覆盖,派发也变成优先级排序,而不是声明由哪个 Definition 呈现事件。认领不要求改动通用 Definition,两个认领冲突时直接失败,而不是静默排序。
|
||||
|
||||
**用注册表声明真人来源,并通过用户文本规则渲染回答。** 回答会保持为通用消息 Node,只定制文本。Chat 的 message 与 Inbox Definition 需要一个依赖 service 的判断函数,注册表变化必须重新组装所有已绑定的 Conversation,文本规则还要把消息来源当作 fact 读取。认领让回答留在它自己的 Definition 中。
|
||||
|
||||
**在持久数据中标记真人撰写的来源。** 消息来源上的通用字段可以让通用 Definition 自行分类回答,但这会改动持久化类型,已发布版本记录的回答也不带这个字段。
|
||||
|
||||
**在 `input-message` 中跳过回答的 kind。** 通用的 Chat Definition 会点名插件的来源 kind。
|
||||
|
||||
**在分组输出层去重。** 这是被替换的机制:重复 Node 仍留在存储里,对其他所有 Chat 读取方可见,每条分组路径都要重复这一聚合。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 被认领的事件在每个被认领 target 上恰好只有一个 Context,Node Store 中不再残留隐藏的重复节点。
|
||||
- 认领冲突在派发时失败,而不是在注册时失败,因为认领由事件数据逐个决定。
|
||||
- 派发对每个事件的 match 多遍历一次,通常一到三个;没有认领的事件不产生额外分配。
|
||||
- 插件在一个 target 上呈现通用 Definition 事件族中的一部分时,使用认领即可,不需要通用 Definition 配合。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts`("exclusive matches"):两种注册顺序与实时追加下,认领都会对同 target 的其他 Definition 屏蔽该事件;其他 target 与无 target 的 Definition 照常接收,fallback 不运行;两次认领与无 target 认领会抛错。
|
||||
- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`:回答的 match 认领其事件;同时持有两个消息 Definition 的 assembler 只为回答产生一个 Chat Node。
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for 2026-09-20-unified-typert-remote-metadata.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md
|
||||
/agent-note-unified-typert-remote-metadata:
|
||||
en: 463246d8c8732192
|
||||
zh: cdb9f8ca8cfe4635
|
||||
/agent-note-unified-typert-remote-metadata/problem:
|
||||
en: 3aa575e4c0778df1
|
||||
zh: 5277fc49b040da50
|
||||
/agent-note-unified-typert-remote-metadata/technical-architecture:
|
||||
en: eb12e56cdcc7c76b
|
||||
zh: bebff68d354c1ba7
|
||||
/agent-note-unified-typert-remote-metadata/proposal:
|
||||
en: f5dcecbcae90a898
|
||||
zh: 923cf3a3074a3a2d
|
||||
/agent-note-unified-typert-remote-metadata/proposal/shared-generation-two-access-modes:
|
||||
en: 733398b2346d7438
|
||||
zh: d4e827b9dbd16d67
|
||||
/agent-note-unified-typert-remote-metadata/proposal/downstream-migration-targets:
|
||||
en: 261dab3eb480a0c0
|
||||
zh: 13c3226d316d8d5e
|
||||
/agent-note-unified-typert-remote-metadata/proposal/paired-usage-examples:
|
||||
en: 7ef7dd38e2015801
|
||||
zh: b97cd179bf828406
|
||||
/agent-note-unified-typert-remote-metadata/alternatives-considered:
|
||||
en: 151b1f66af030bc5
|
||||
zh: 44fea0898acfbdcb
|
||||
/agent-note-unified-typert-remote-metadata/acceptance-criteria:
|
||||
en: d011ec1516706b20
|
||||
zh: 945c6e3eba101368
|
||||
/agent-note-unified-typert-remote-metadata/risks:
|
||||
en: a770478eac8f5fd1
|
||||
zh: f24aa378f875f47f
|
||||
@@ -0,0 +1,191 @@
|
||||
# Agent Note: Unified Typert/Remote metadata
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-09-20-unified-typert-remote-metadata.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations. Tool consumers need stricter input-object validation without changing ordinary Host dispatch. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer.
|
||||
|
||||
The original #4644 source-definition decision delivered this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carried those definitions; source generation had its own inventory, freshness, publication, and exclusion rules. This note replaces that delivery mechanism and makes input strictness consumer-owned.
|
||||
|
||||
Both delivery paths derive from the same [compiler-independent model](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.md). Separate Host and Client Programs require generated cross-face declarations, not committed generated source. An explicit prebuild is acceptable when exact schemas are needed; ordinary ESM artifacts can supply offline metadata without a live Host. The [targeted-call decision](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) still owns receiver lookup, Gateway dispatch, and contribution lifetimes.
|
||||
|
||||
## Technical architecture
|
||||
|
||||
| Export | Responsibility | Registration |
|
||||
|---|---|---|
|
||||
| Host `./typert` | `TYPERT`: local reflection, schemas, and an `invocations` getter exposing a private info instance's descriptors. | Loader imports and validates the contribution, then calls `ctx.typert.register(TYPERT)`. |
|
||||
| Host-for-consumer `./remote` | Default export and `TYPERT_REMOTE`: a `TypertRemoteInfo` with descriptors and typed `methods`, plus generated Remote declarations/maps. | Harness metadata consumers call `ctx.typert.remotes.register(TYPERT_REMOTE)`; live consumers use `ctx.remote.$mount(TYPERT_REMOTE)`, which already registers it. Independent readers need no Registry. |
|
||||
| Client `./client/typert` | The Client face's own local reflection and schemas. | Its local Registry; this is not the Host Remote projection. |
|
||||
|
||||
[Registry](../../../../packages/typert/registry/src/service.ts) instances belong to their Cordis Context and process. Host and Client entries share implementation, not state. Existing local/Remote `get()` and `list()` preserve the registered descriptor objects; registration retains atomic duplicate checks, notifications, tombstones, and fiber-owned disposal. No Registry API, store, subscription, metadata RPC, or cross-process synchronization is added. Separate Programs and processes do not share object identity.
|
||||
|
||||
The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods }, z)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Every parameter has both `codec` and `strictCodec` from the same checker-resolved type model, with no per-method policy in the analyzer or model. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export.
|
||||
|
||||
The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains only the spec and the Zod runtime explicitly supplied by the generated artifact, without constructing schemas. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote.
|
||||
|
||||
A direct method's `input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue<Input>`; `strict?: boolean` is the only option. `method.input()` and `{ strict: false }` use normal codecs: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys stay open, and constrained dictionary values still validate. Consumers select one view for matching `input.create().parse(raw)` and `input.jsonSchema`. Host dispatch and the single `result` codec retain normal behavior, including binary `encode`/`decode`.
|
||||
|
||||
Each input-policy view, its named parser, and its JSON Schema projection are independently lazy and cached. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema<T>` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. Generated artifacts import and supply Zod, including Remote endpoints without parameters or exported schemas. The pure leaf retains only Zod type imports and has no runtime module dependencies; the protocol root re-exports the factory and imports Cordis without adding Zod to every protocol consumer's static module graph. Imports and metadata/schema access never invoke the network.
|
||||
|
||||
Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise<RemoteResult<DecodedOutput>>` or `RemoteStreamHandle<Output, Uplink>` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. The fifth `TypertRemoteMethod` generic retains the carrier-decoded unary byte type independently of the result parser's authored output.
|
||||
|
||||
Documentation and JSON Schema are optional. Each unsupported input variant or result projection records its own `jsonSchemaError` while preserving parsers and independent documentation; only the dedicated projection error is caught. A consumer explicitly selecting a tool requires its description and supported input JSON Schema. Output parsing remains required even when optional output JSON Schema is absent. Native `dsh-tools` registration additionally requires `output.schema`: a parser alone does not satisfy it. These consumer requirements must not prevent an otherwise valid Remote method from being generated.
|
||||
|
||||
## Proposal
|
||||
|
||||
Complete the ordinary-artifact migration in the downstream Session-client, CLI, MCP, and native-tool packages. This note remains proposed because that migration is not complete.
|
||||
|
||||
### Shared generation, two access modes
|
||||
|
||||
Framework consumers load ordinary metadata into their own Registry. Independent consumers may import `./remote` directly without Cordis, Host Service activation, or a connection. Both read the same generated business definition. Keep separate Host/Client Programs and analyze all contributors of each face so focused emission retains external declaration augmentations. Registration does not expose every reflected method as a callable endpoint or tool.
|
||||
|
||||
Strict source startup explicitly prebuilds and loads ordinary `./typert` through Loader. Limited descriptor-free fallback still supports basic arguments, signals, cancellation, streams, JSON conversion, and unary byte attachments, but cannot recover erased fields, constraints, or tool documentation. Once a strict local descriptor has been observed, its withdrawal must refuse invocation rather than downgrade to fallback. `@Remote` accepts only `name` and `mode`; consumers choose input policy without a separate developer annotation.
|
||||
|
||||
Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validates static codec specs and callable `input`/`invoke` without calling the selector, parser factories, or projections to register a contribution.
|
||||
|
||||
### Downstream migration targets
|
||||
|
||||
The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool consumers that are not present as complete packages on this branch. Its Session client selects 13 Session methods plus Workspace listing from source exports. Migrating that tree requires updating selection, connection-time mounts, offline CLI initialization, MCP presentation, and native-tool registration together; deleting source exports alone is insufficient.
|
||||
|
||||
| Consumer | Metadata adaptation | Execution retained |
|
||||
|---|---|---|
|
||||
| Browser / Node Remote callers | Import ordinary `./remote`; mount once in the live Context. | Generated namespaces, Gateway, authenticated Connection, and target controllers. |
|
||||
| Offline CLI help / parsing | Register before target discovery in the CLI profile's metadata Context; select `input({ strict: true })`. | No connection for help; a separate live Context mounts when calling. |
|
||||
| MCP discovery / calls | Read selected Registry entries with `input({ strict: true })`; retain consumer-owned tool descriptions and envelopes. | `tools/list` is offline; `tools/call` uses the same Session client. External MCP clients receive protocol JSON, not Registry modules. |
|
||||
| Native tools | Read Host local method info; require the tool registration's `output.schema`. | Parse at the tool boundary, then call same-process controllers directly. |
|
||||
| Independent adapters | Read typed `TYPERT_REMOTE.methods` directly. | Use their chosen Remote runtime only when invoking. |
|
||||
|
||||
Current-session selection and attachment preparation remain consumer-owned; CLI/MCP intake validates completed business arguments with the same strict input view used during selection. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work.
|
||||
|
||||
### Paired usage examples
|
||||
|
||||
The old examples are historical comparisons of source-export consumers in the dependent changes. The new examples use ordinary artifacts. These fragments omit surrounding plugin declarations and include removed APIs. `raw`, `request`, `signal`, and an authenticated, mounted, unscoped `remote` are caller inputs, not new framework APIs.
|
||||
|
||||
**Operation owner and strict Host.** Preserve the business signature; leave generated registration to ordinary Typert Loader instead of the Service constructor.
|
||||
|
||||
```text
|
||||
// Old source delivery.
|
||||
import { TYPERT_REMOTE } from './remote.generated.ts'
|
||||
// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE })
|
||||
@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true })
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
|
||||
// Ordinary delivery; no generated-source import or constructor definitions.
|
||||
// Constructor: super(ctx, 'sessionController', { namespace: 'session' })
|
||||
@Remote({ name: 'page' })
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
```
|
||||
|
||||
**Browser and Node invocation.** A source-export consumer changes `/remote-source` to `/remote`; existing ordinary consumers already use this path. The live Context mounts once, without separate Registry registration.
|
||||
|
||||
```text
|
||||
import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
await ctx.remote.$mount(TYPERT_REMOTE)
|
||||
const result = await ctx.remote.session.page(request, signal)
|
||||
const stream = ctx.remote.session.follow(followRequest, signal)
|
||||
try {
|
||||
for await (const frame of stream) consume(frame)
|
||||
} finally {
|
||||
await stream.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
**Offline CLI and framework metadata.** The old selection reads a source table; the replacement reads the profile-owned Registry before target discovery. The two snippets are alternatives, not simultaneous declarations.
|
||||
|
||||
```text
|
||||
// Old selection.
|
||||
import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source'
|
||||
const page = REMOTE_METHODS['session/page']
|
||||
```
|
||||
|
||||
```text
|
||||
// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller.
|
||||
import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info'
|
||||
ctx.typert.remotes.register(TYPERT_REMOTE)
|
||||
const page = ctx.typert.remotes.get('session/page')
|
||||
if (page === undefined || !isTypertRemoteMethod(page)) {
|
||||
throw new Error('session/page generated direct method is not loaded')
|
||||
}
|
||||
```
|
||||
|
||||
The registration belongs to that plugin's fiber. Help uses `description` and `input({ strict: true })`; the typed facade can retain type-only imports from ordinary generated declarations. Explicit tool selection does not mutate registered metadata.
|
||||
|
||||
**CLI input and dispatch.** After local `current` resolution and attachment preparation, either metadata access path uses the same invocation sequence:
|
||||
|
||||
```text
|
||||
if (page.mode === 'stream') throw new Error('session/page must be unary')
|
||||
const input = page.input({ strict: true })
|
||||
const arguments_ = input.create().parse(raw)
|
||||
const result = await page.invoke(remote, arguments_, signal)
|
||||
if (!result.ok) throw result.error
|
||||
const output = page.result.create().parse(result.value)
|
||||
```
|
||||
|
||||
**MCP discovery and calls.** Selection validates tool-specific requirements; generation itself does not require them.
|
||||
|
||||
```text
|
||||
const input = page.input({ strict: true })
|
||||
if (!page.description) throw new Error('session/page has no tool description')
|
||||
if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema')
|
||||
const tool = {
|
||||
name: 'remote_session_page',
|
||||
description: page.description,
|
||||
inputSchema: input.jsonSchema,
|
||||
...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }),
|
||||
}
|
||||
```
|
||||
|
||||
This compact object shows only the metadata dependency. The real MCP adapter retains its `{ arguments, current, content }` input and `{ ok, value }` / failure output envelopes. Listing requires no connection; calling performs intake, local preparation, parsing through `input.create()`, invocation, and output parsing. Optional output projection does not remove output validation.
|
||||
|
||||
**Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Native tools can select `method.input({ strict: true })` for matching parsing and input projection. Native registration still requires supported input and result projections, including core Tool's mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call:
|
||||
|
||||
```text
|
||||
const value = await ctx.sessionController.page(request, signal)
|
||||
```
|
||||
|
||||
**Independent consumers.** Read the ordinary info instance's typed index; no Registry is required:
|
||||
|
||||
```text
|
||||
import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
const page = TYPERT_REMOTE.methods['session/page']
|
||||
const arguments_ = page.input().create().parse(raw)
|
||||
const result = await page.invoke(remote, arguments_, signal)
|
||||
```
|
||||
|
||||
**Strict source startup.** Build ordinary artifacts, then use the supported source launcher:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
pnpm dsh --profile <profile-name> <profile-arguments>
|
||||
```
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Committed source:** meets offline needs but adds a redundant delivery and maintenance path; explicit ordinary prebuild is sufficient.
|
||||
- **Nested descriptor metadata and a separate method table:** duplicate result and parameter facts and add another identity to track. One spec derives direct helpers on the descriptor itself; `TypertRemoteInfo` extends the existing contribution and retains `descriptors` so Gateway and Registry keep their current registration APIs.
|
||||
- **Mandatory Registry for every importer:** adds lifecycle requirements without information for standalone consumers.
|
||||
- **Fallback only:** cannot supply erased validation or tool documentation; retain it only for its supported subset.
|
||||
- **Handwritten adapter schemas or runtime TypeScript analysis:** duplicate ownership or introduce runtime compiler requirements already handled by build-time extraction.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention.
|
||||
- Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections.
|
||||
- Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results.
|
||||
- Protocol source and built entries have no Zod runtime dependency; generated artifacts explicitly supply Zod, zero-argument endpoints create empty inputs that reject extra fields, and the [production headless-profile Loader regression](<../../../../packages/context/time-context/tests/time-context.e2e.ts>) asserts actual Session events.
|
||||
- Registry identity, atomic registration, withdrawal, and disposal remain intact.
|
||||
- Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs.
|
||||
- Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback.
|
||||
- Source static checks do not consume stale generated declarations; artifact checks declare and rebuild their inputs. No source-definition opt-in, source export, inventory, or freshness rule remains in production.
|
||||
|
||||
## Risks
|
||||
|
||||
Full downstream profile composition remains follow-up work in those packages; the generated metadata is not evidence that every #4111 operation or CLI/MCP application has migrated. The deliberate trade-off is an explicit prebuild for full source-runtime metadata. Missing or stale artifacts require regeneration, not a validation downgrade or a second handwritten schema.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Agent Note: 统一 Typert/Remote 元数据
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-09-20-unified-typert-remote-metadata.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用。工具消费方需要更严格的输入对象校验,但不能改变普通 Host 分发。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。
|
||||
|
||||
#4644 原有的源码定义决策通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息;Host 绑定携带定义,源码生成另有清单、新鲜度、发布和排除规则。本 note 替代该交付机制,并将输入严格性改为由消费方选择。
|
||||
|
||||
两种交付路径均来自同一个[编译器无关模型](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md)。独立的 Host 与 Client Program 要求生成跨编译面声明,而非提交生成源码。需要精确 schema 时,显式预构建是可接受的;普通 ESM 产物无需运行中的 Host 即可提供离线元数据。[定向调用决策](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)仍负责接收者查找、Gateway 分发和贡献生命周期。
|
||||
|
||||
## 技术架构
|
||||
|
||||
| 导出 | 职责 | 注册方式 |
|
||||
|---|---|---|
|
||||
| Host `./typert` | `TYPERT`:本地反射、schema,以及暴露私有 info 实例描述符的 `invocations` getter。 | Loader 导入并验证 contribution,再调用 `ctx.typert.register(TYPERT)`。 |
|
||||
| 面向消费方的 Host `./remote` | 默认导出和 `TYPERT_REMOTE`:带有描述符和类型化 `methods` 的 `TypertRemoteInfo`,以及生成的 Remote 声明/映射。 | Harness 元数据消费方调用 `ctx.typert.remotes.register(TYPERT_REMOTE)`;实际调用方使用 `ctx.remote.$mount(TYPERT_REMOTE)`,它已经包含注册。独立读取方无需 Registry。 |
|
||||
| Client `./client/typert` | Client 编译面自己的本地反射与 schema。 | 本地 Registry;这不是 Host Remote 投影。 |
|
||||
|
||||
[Registry](../../../../packages/typert/registry/src/service.ts) 实例属于各自的 Cordis Context 和进程。Host 与 Client 入口共享实现,而非状态。既有本地/Remote `get()` 与 `list()` 保留已注册的描述符对象;注册保留原子重复检查、通知、tombstone 和 fiber 管理的释放。不新增 Registry API、存储、订阅、元数据 RPC 或跨进程同步。独立 Program 和进程不共享对象身份。
|
||||
|
||||
[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods }, z)`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。每个参数都有从同一 checker 解析后类型模型派生的 `codec` 和 `strictCodec`,分析器与模型不携带逐方法策略。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。
|
||||
|
||||
[factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec 和生成产物显式传入的 Zod 运行时,不构造 schema。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。
|
||||
|
||||
直接方法的 `input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue<Input>`;唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 使用普通 codec:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。消费方选择一个视图,通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影。Host 分发与唯一的 `result` codec 保留普通行为,包括二进制 `encode`/`decode`。
|
||||
|
||||
每种输入策略的视图、具名解析器及 JSON Schema 投影都独立惰性求值并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema<T>` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。生成产物导入并传入 Zod,包括没有参数或导出 schema的 Remote 端点。纯叶入口只保留 Zod 类型导入,没有运行时模块依赖;协议根入口重新导出 factory 并导入 Cordis,但不会因此将 Zod 加入所有协议消费方的静态模块图。导入和元数据/schema 读取绝不发起网络调用。
|
||||
|
||||
Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise<RemoteResult<DecodedOutput>>` 或 `RemoteStreamHandle<Output, Uplink>`;流迭代、`send`、`end` 和 `dispose` 均保留。`TypertRemoteMethod` 的第五个泛型参数独立于结果解析器的原始输出,保留载体解码后的一元字节类型。
|
||||
|
||||
文档和 JSON Schema 可选。每个不受支持的输入变体或结果投影记录各自的 `jsonSchemaError`,同时保留解析器及独立文档;只捕获专用投影错误。显式选择工具的消费方要求工具说明和受支持的输入 JSON Schema。即使缺少可选输出 JSON Schema,输出解析仍然必需。原生 `dsh-tools` 注册额外要求 `output.schema`:仅有解析器并不满足要求。这些消费方要求不得阻止本来有效的 Remote 方法生成。
|
||||
|
||||
## Proposal
|
||||
|
||||
在下游 Session-client、CLI、MCP 和原生工具包中完成普通产物迁移。本 note 保持 proposed,因为该迁移尚未完成。
|
||||
|
||||
### 共享生成,两种访问方式
|
||||
|
||||
框架消费方将普通元数据加载到自己的 Registry。独立消费方可以直接导入 `./remote`,无需 Cordis、Host Service 激活或连接。两者读取同一份生成业务定义。保留独立的 Host/Client Program,并分析每个编译面的所有贡献方,使局部生成保留外部声明合并。注册不会将每个反射方法都暴露为可调用 endpoint 或工具。
|
||||
|
||||
严格源码启动显式预构建,并通过 Loader 加载普通 `./typert`。有限的无 descriptor fallback 仍支持基本参数、signal、取消、流式调用、JSON 转换和一元字节附件,但无法恢复被擦除的字段、约束或工具文档。一旦观察到严格的本地 descriptor,撤销后必须拒绝调用,而不是降级到 fallback。`@Remote` 只接受 `name` 和 `mode`;消费方选择输入策略,无需另加开发者注解。
|
||||
|
||||
Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验静态 codec spec 及可调用的 `input`/`invoke`,注册贡献时不调用选择器、解析器 factory 或投影。
|
||||
|
||||
### 下游迁移目标
|
||||
|
||||
已检查的 #4111 树包含 Session-client、CLI、MCP 和原生工具消费方,当前分支并不包含这些完整 package。其 Session client 从源码导出选择 13 个 Session 方法及 Workspace 列举。迁移该树需要一并更新选择、连接时挂载、离线 CLI 初始化、MCP 呈现及原生工具注册;仅删除源码导出并不足够。
|
||||
|
||||
| 消费方 | 元数据适配 | 保留的执行方式 |
|
||||
|---|---|---|
|
||||
| Browser / Node Remote 调用方 | 导入普通 `./remote`;在实际调用 Context 中挂载一次。 | 生成 namespace、Gateway、认证 Connection 和目标 controller。 |
|
||||
| 离线 CLI 帮助 / 解析 | 在 CLI profile 的元数据 Context 中,于目标发现之前注册;选择 `input({ strict: true })`。 | 帮助不连接;调用时由独立的实际调用 Context 挂载。 |
|
||||
| MCP 发现 / 调用 | 读取选中的 Registry 条目并选择 `input({ strict: true })`;保留消费方自有工具说明和 envelope。 | `tools/list` 离线;`tools/call` 使用相同 Session client。外部 MCP 客户端收到协议 JSON,而不是 Registry 模块。 |
|
||||
| 原生工具 | 读取 Host 本地方法信息;要求工具注册的 `output.schema`。 | 在工具边界解析,然后直接调用同进程 controller。 |
|
||||
| 独立适配器 | 直接读取类型化 `TYPERT_REMOTE.methods`。 | 仅调用时使用其选择的 Remote 运行时。 |
|
||||
|
||||
当前 Session 选择和附件准备仍由消费方拥有;CLI/MCP 输入接收使用选择时的同一严格输入视图验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。
|
||||
|
||||
### 新旧接入示例
|
||||
|
||||
旧示例是依赖改动中源码导出消费方的历史对照。新示例使用普通产物。这些片段省略外围插件声明,且包含已移除的 API。`raw`、`request`、`signal` 和已认证、已挂载、无作用域的 `remote` 是调用方输入,不是新增框架 API。
|
||||
|
||||
**操作拥有者与严格 Host。** 保留业务签名;将生成定义注册交给普通 Typert Loader,而非 Service 构造函数。
|
||||
|
||||
```text
|
||||
// Old source delivery.
|
||||
import { TYPERT_REMOTE } from './remote.generated.ts'
|
||||
// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE })
|
||||
@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true })
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
|
||||
// Ordinary delivery; no generated-source import or constructor definitions.
|
||||
// Constructor: super(ctx, 'sessionController', { namespace: 'session' })
|
||||
@Remote({ name: 'page' })
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
```
|
||||
|
||||
**Browser 与 Node 调用。** 源码导出消费方将 `/remote-source` 改为 `/remote`;已有普通消费方本来就使用此路径。实际调用 Context 挂载一次,不额外向 Registry 注册。
|
||||
|
||||
```text
|
||||
import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
await ctx.remote.$mount(TYPERT_REMOTE)
|
||||
const result = await ctx.remote.session.page(request, signal)
|
||||
const stream = ctx.remote.session.follow(followRequest, signal)
|
||||
try {
|
||||
for await (const frame of stream) consume(frame)
|
||||
} finally {
|
||||
await stream.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
**离线 CLI 与框架元数据。** 旧选择读取源码方法表;替代方式在目标发现之前读取 profile 拥有的 Registry。两个片段是不同选择,不是同时声明。
|
||||
|
||||
```text
|
||||
// Old selection.
|
||||
import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source'
|
||||
const page = REMOTE_METHODS['session/page']
|
||||
```
|
||||
|
||||
```text
|
||||
// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller.
|
||||
import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info'
|
||||
ctx.typert.remotes.register(TYPERT_REMOTE)
|
||||
const page = ctx.typert.remotes.get('session/page')
|
||||
if (page === undefined || !isTypertRemoteMethod(page)) {
|
||||
throw new Error('session/page generated direct method is not loaded')
|
||||
}
|
||||
```
|
||||
|
||||
注册属于该插件的 fiber。帮助使用 `description` 和 `input({ strict: true })`;类型化 facade 可以保留对普通生成声明的 type-only 导入。显式工具选择不修改已注册元数据。
|
||||
|
||||
**CLI 输入与分发。** 本地 `current` 解析和附件准备之后,两种元数据访问方式使用同样的调用步骤:
|
||||
|
||||
```text
|
||||
if (page.mode === 'stream') throw new Error('session/page must be unary')
|
||||
const input = page.input({ strict: true })
|
||||
const arguments_ = input.create().parse(raw)
|
||||
const result = await page.invoke(remote, arguments_, signal)
|
||||
if (!result.ok) throw result.error
|
||||
const output = page.result.create().parse(result.value)
|
||||
```
|
||||
|
||||
**MCP 发现与调用。** 选择时验证工具特有要求;生成过程本身不要求它们。
|
||||
|
||||
```text
|
||||
const input = page.input({ strict: true })
|
||||
if (!page.description) throw new Error('session/page has no tool description')
|
||||
if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema')
|
||||
const tool = {
|
||||
name: 'remote_session_page',
|
||||
description: page.description,
|
||||
inputSchema: input.jsonSchema,
|
||||
...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }),
|
||||
}
|
||||
```
|
||||
|
||||
这个简化对象只展示元数据依赖。真实 MCP 适配器保留 `{ arguments, current, content }` 输入及 `{ ok, value }` / 失败输出 envelope。列举无需连接;调用执行输入接收、本地准备、通过 `input.create()` 解析、调用及输出解析。可选输出投影不会移除输出验证。
|
||||
|
||||
**原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生工具可以选择 `method.input({ strict: true })`,取得匹配的解析与输入投影。原生注册仍要求受支持的输入与结果投影,包括 core Tool 必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用:
|
||||
|
||||
```text
|
||||
const value = await ctx.sessionController.page(request, signal)
|
||||
```
|
||||
|
||||
**独立消费方。** 读取普通 info 实例的类型化索引,不要求 Registry:
|
||||
|
||||
```text
|
||||
import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
const page = TYPERT_REMOTE.methods['session/page']
|
||||
const arguments_ = page.input().create().parse(raw)
|
||||
const result = await page.invoke(remote, arguments_, signal)
|
||||
```
|
||||
|
||||
**严格源码启动。** 构建普通产物,再使用受支持的源码启动器:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
pnpm dsh --profile <profile-name> <profile-arguments>
|
||||
```
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **提交源码:** 满足离线需求,但增加冗余交付和维护路径;显式普通预构建已经足够。
|
||||
- **嵌套描述符元数据与独立方法表:** 重复结果和参数事实,并增加需要跟踪的对象身份。一份 spec 直接在描述符上派生调用辅助能力;`TypertRemoteInfo` 扩展既有 contribution 并保留 `descriptors`,让 Gateway 与 Registry 沿用现有注册 API。
|
||||
- **所有导入方强制使用 Registry:** 为独立消费方增加生命周期要求,却不增加信息。
|
||||
- **仅用 fallback:** 无法提供被擦除的验证或工具文档;仅为受支持子集保留。
|
||||
- **手写适配器 schema 或运行时 TypeScript 分析:** 重复承担所有权,或引入构建期提取已解决的运行时编译器要求。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。
|
||||
- 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。
|
||||
- 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。
|
||||
- 协议源码与构建入口没有 Zod 运行时依赖;生成产物显式提供 Zod,无参端点也能创建拒绝额外字段的空输入对象;[生产 headless profile 的 Loader 回归](<../../../../packages/context/time-context/tests/time-context.e2e.ts>)断言真实 Session 事件。
|
||||
- Registry 身份、原子注册、撤销和释放保持不变。
|
||||
- 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。
|
||||
- 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。
|
||||
- 源码静态检查不消费过期生成声明;产物检查声明并重建输入。生产代码不保留源码定义 opt-in、源码导出、清单或新鲜度规则。
|
||||
|
||||
## Risks
|
||||
|
||||
完整下游 profile 组合仍需在相应 package 中继续完成;生成的元数据不代表每个 #4111 操作或 CLI/MCP 应用都已迁移。明确接受的取舍是完整源码运行时元数据需要显式预构建。缺失或过期产物要求重新生成,而不是降低验证或另建一套手写 schema。
|
||||
@@ -54,6 +54,10 @@ Every menu, popover, and tooltip must be verified for all three before merge:
|
||||
|
||||
Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge.
|
||||
|
||||
## Responsive layouts
|
||||
|
||||
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes.
|
||||
|
||||
## Platform window adaptation
|
||||
|
||||
- Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: dsh-responsive-ui
|
||||
description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
|
||||
---
|
||||
|
||||
# Responsive client UI
|
||||
|
||||
## 1. Minimum viewport
|
||||
|
||||
Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed.
|
||||
|
||||
## 2. Decision order
|
||||
|
||||
1. First adjust space allocation or wrap elements onto another line; then consider text truncation, icon-only controls, or moving icons into the More menu.
|
||||
2. Primary actions and primary states must not be omitted.
|
||||
3. Do not hide conflicts through global clipping, reducing all font sizes, or shrinking click targets.
|
||||
4. Reduce font sizes only when specifically required by product design.
|
||||
|
||||
## 3. Text truncation
|
||||
|
||||
1. Truncated content must be fully available on a secondary page or by opening a menu. Example: Session statistics below the input box.
|
||||
2. Truncation must not lose errors or other key information.
|
||||
3. Prefer single-line truncation for tab navigation, visible selectors, buttons, tags, and similar components.
|
||||
4. Important information that is not available on another page must be displayed in full without truncation.
|
||||
|
||||
## 4. Titles
|
||||
|
||||
1. Prefer displaying page titles in full. Move the action area onto another line when necessary. Example: the Plugins page's title wrapping behavior.
|
||||
2. Prefer natural wrapping for card titles. If the number of lines is limited, the full title must remain available to view.
|
||||
|
||||
## 5. Icon-only controls
|
||||
|
||||
1. Reducing a control to an icon requires both:
|
||||
- The icon clearly conveys its meaning, or the operation is familiar to users.
|
||||
- A tooltip appears on hover; add one if needed.
|
||||
2. Do not reduce “More account information” to an icon, because it navigates to a separate page.
|
||||
3. The model selector in the input box can be reduced to an icon because users see it frequently and can open its menu to switch models.
|
||||
4. Another example is the linked conversation in an automation task.
|
||||
|
||||
## 6. Mobile hover behavior
|
||||
|
||||
1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this.
|
||||
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover.
|
||||
3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction.
|
||||
4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result.
|
||||
|
||||
## 7. Mobile tooltips
|
||||
|
||||
1. Do not show tooltips that only provide auxiliary information such as action names or keyboard shortcuts on mobile. For example, omit the “Copy” tooltip on a copy button; tapping still performs the original action directly.
|
||||
2. Tooltips that provide supplementary explanations must support tap-to-show on mobile. For example, the information tooltip beside the Plugins page description.
|
||||
|
||||
## 8. Mobile keyboard
|
||||
|
||||
On mobile, do not raise the input keyboard automatically unless it is explicitly intended.
|
||||
@@ -141,7 +141,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
- `@earendil-works/pi-ai@1.0.2` — [`patches/@earendil-works__pi-ai@1.0.2.patch`](patches/@earendil-works__pi-ai@1.0.2.patch)
|
||||
- `@earendil-works/pi-ai@1.1.0` — [`patches/@earendil-works__pi-ai@1.1.0.patch`](patches/@earendil-works__pi-ai@1.1.0.patch)
|
||||
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
|
||||
@@ -528,7 +528,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli
|
||||
]))
|
||||
}
|
||||
if (mode === 'python-only') expect(stderr).not.toContain('ENOENT')
|
||||
if (mode === 'missing-assets') expect(stderr).toContain('check_office.py')
|
||||
if (mode === 'missing-assets') expect(stderr).toContain('SKILL.md')
|
||||
await send(3, 'shutdown')
|
||||
const exit = await child
|
||||
expect(exit.timedOut).toBe(false)
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 3724c53da4f7647b
|
||||
zh: 76ae549d43c99bb4
|
||||
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
|
||||
en: 22f1c529983660d5
|
||||
zh: e2dad2c6b41f07ce
|
||||
en: 3368ff9b955ada69
|
||||
zh: e9209ef6ad4fd305
|
||||
/deepseek-harness-desktop/bundled-command-runtime:
|
||||
en: 372297005b57a08b
|
||||
zh: 074a635ac17e4362
|
||||
|
||||
@@ -100,7 +100,7 @@ Signed Windows packaging scans the primary runtime and application production de
|
||||
|
||||
Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
|
||||
|
||||
Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries and the shared structural checker; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR so Python can read resources. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
|
||||
Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
|
||||
|
||||
The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产
|
||||
|
||||
Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
|
||||
|
||||
Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库和共享结构检查器;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`,便于 Python 读取资源。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
|
||||
Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
|
||||
|
||||
该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type DesktopRuntimePatch =
|
||||
|
||||
/** Every workspace patch requires an explicit Desktop decision; runtime-only entries may add or replace a patch. */
|
||||
export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatch>> = {
|
||||
'@earendil-works/pi-ai@1.0.2': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' },
|
||||
'@earendil-works/pi-ai@1.1.0': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' },
|
||||
'@electron/[email protected]': { scope: 'workspace-only', reason: 'Signing executes on the build host.' },
|
||||
'@fortune-sheet/[email protected]': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' },
|
||||
'@fortune-sheet/[email protected]': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' },
|
||||
@@ -20,6 +20,6 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
|
||||
|
||||
/** Reviewed Desktop dependency constraints, independent of patch selection. */
|
||||
export const DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES: Readonly<Record<string, string>> = {
|
||||
// The provider declares ^1.0.2; its patch is qualified against 1.0.2.
|
||||
'@earendil-works/pi-ai@^1.0.2': '1.0.2',
|
||||
// The provider declares ^1.1.0; its patch is qualified against 1.1.0.
|
||||
'@earendil-works/pi-ai@^1.1.0': '1.1.0',
|
||||
}
|
||||
@@ -42,11 +42,11 @@ it('classifies the current workspace and shares only runtime patch bytes', () =>
|
||||
const repository = resolve(import.meta.dirname, '../../..')
|
||||
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
|
||||
const plan = prepareRuntimePatches(project, repository)
|
||||
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', '[email protected]'])
|
||||
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.1.0', '[email protected]'])
|
||||
expect(plan.workspaceOnly).toHaveLength(6)
|
||||
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
|
||||
expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
|
||||
expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2')
|
||||
expect(settings.overrides['@earendil-works/pi-ai@^1.1.0']).toBe('1.1.0')
|
||||
expect(readFileSync(join(repository, 'pnpm-lock.yaml'))).toEqual(before)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Background jobs above the Browser dock
|
||||
|
||||
```json
|
||||
{
|
||||
"collapsed": {
|
||||
"dockMode": "push",
|
||||
"bodyMounted": true,
|
||||
"fixed": true,
|
||||
"anchored": true,
|
||||
"viewportBounded": true,
|
||||
"overlapsBrowser": true,
|
||||
"aboveBrowser": true
|
||||
},
|
||||
"expanded": {
|
||||
"dockMode": "push",
|
||||
"bodyMounted": true,
|
||||
"fixed": true,
|
||||
"anchored": true,
|
||||
"viewportBounded": true,
|
||||
"overlapsBrowser": true,
|
||||
"aboveBrowser": true
|
||||
},
|
||||
"resized": {
|
||||
"dockMode": "push",
|
||||
"bodyMounted": true,
|
||||
"fixed": true,
|
||||
"anchored": true,
|
||||
"viewportBounded": true,
|
||||
"overlapsBrowser": true,
|
||||
"aboveBrowser": true
|
||||
},
|
||||
"escapeRestoresFocus": true,
|
||||
"outsideDismisses": true
|
||||
}
|
||||
```
|
||||
@@ -20,7 +20,7 @@
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "azure"
|
||||
- option "baseten"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "azure"
|
||||
- option "baseten"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Plugin header responsive layout
|
||||
|
||||
en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
|
||||
A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.
|
||||
Add menu dismisses on Escape and returns focus in both locales.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Automation tasks header
|
||||
|
||||
en 1680px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 800px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 520px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 440px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 390px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 430px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 1024px: title and New single-line, New beside the title; contained; New retains focus
|
||||
en 509px with sidebar expanded: title single-line; New moves below the title
|
||||
zh 1680px: title and New single-line, New beside the title; contained; New retains focus
|
||||
zh 800px: title and New single-line, New beside the title; contained; New retains focus
|
||||
zh 520px: title and New single-line, New beside the title; contained; New retains focus
|
||||
zh 440px: title and New single-line, New beside the title; contained; New retains focus
|
||||
zh 390px: title and New single-line, New beside the title; contained; New retains focus
|
||||
zh 430px: title and New single-line, New beside the title; contained; New retains focus
|
||||
zh 1024px: title and New single-line, New beside the title; contained; New retains focus
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"stage": "details",
|
||||
"details": true,
|
||||
"tooltips": []
|
||||
},
|
||||
{
|
||||
"stage": "more-hover",
|
||||
"details": true,
|
||||
"tooltips": []
|
||||
},
|
||||
{
|
||||
"stage": "archive-before-dwell",
|
||||
"details": true,
|
||||
"tooltips": []
|
||||
},
|
||||
{
|
||||
"stage": "archive",
|
||||
"details": true,
|
||||
"tooltips": [
|
||||
"Archive"
|
||||
]
|
||||
},
|
||||
{
|
||||
"stage": "pin",
|
||||
"details": true,
|
||||
"tooltips": [
|
||||
"Pin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"stage": "back-to-title",
|
||||
"details": true,
|
||||
"tooltips": []
|
||||
},
|
||||
{
|
||||
"stage": "menu-closed",
|
||||
"details": false,
|
||||
"tooltips": []
|
||||
},
|
||||
{
|
||||
"stage": "row-reentered",
|
||||
"details": true,
|
||||
"tooltips": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"stage": "first-before-dwell",
|
||||
"first": false,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "first-shown",
|
||||
"first": true,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "second-instant",
|
||||
"first": false,
|
||||
"second": true
|
||||
},
|
||||
{
|
||||
"stage": "first-instant",
|
||||
"first": true,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "readable-portal",
|
||||
"first": true,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "copied-portal",
|
||||
"first": true,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "portal-return-instant",
|
||||
"first": false,
|
||||
"second": true
|
||||
},
|
||||
{
|
||||
"stage": "outside-cooled",
|
||||
"first": false,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "list-reentry-before-dwell",
|
||||
"first": false,
|
||||
"second": false
|
||||
},
|
||||
{
|
||||
"stage": "list-reentry-shown",
|
||||
"first": true,
|
||||
"second": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"stage": "first-workspace-after-dwell",
|
||||
"exclusive": true
|
||||
},
|
||||
{
|
||||
"stage": "workspace-to-workspace-immediate",
|
||||
"exclusive": true
|
||||
},
|
||||
{
|
||||
"stage": "workspace-to-session-immediate",
|
||||
"exclusive": true
|
||||
},
|
||||
{
|
||||
"stage": "session-to-workspace-immediate",
|
||||
"exclusive": true
|
||||
},
|
||||
{
|
||||
"stage": "list-reentered-after-dwell",
|
||||
"exclusive": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]:
|
||||
- text: {{workspace}}
|
||||
- button "Workspace actions for {{workspace}}"
|
||||
- button "New session in {{workspace}}"
|
||||
- treeitem "New Session" [selected]
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- treeitem "Untitled 1min"
|
||||
- button "Show 7 more sessions"
|
||||
@@ -0,0 +1,199 @@
|
||||
/** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
|
||||
const EXPECTED_DIR = fileURLToPath(new URL('./expected/jobs-menu-layering', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = SessionId('jobs-menu-layering-web-e2e')
|
||||
// The private scaffold cwd owns the barrier; the loop bounds an orphan's lifetime.
|
||||
const RELEASE = '.jobs-menu-layering.release'
|
||||
const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done`
|
||||
|
||||
/** Measure actual overlap and hit testing; ARIA alone cannot expose clipping or stacking. */
|
||||
function placement(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('ul[aria-label="Background jobs"]')
|
||||
const anchor = document.querySelector<HTMLElement>('button[aria-label="1 background job running"]')
|
||||
const frame = document.querySelector<HTMLElement>('[data-sidebar-browser-frame]')
|
||||
const dock = document.querySelector<HTMLElement>('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
if (list === null || anchor === null || frame === null || dock === null) {
|
||||
throw new Error('job menu, trigger, or Browser dock is missing')
|
||||
}
|
||||
const menu = list.closest<HTMLElement>('[data-menu-material]') ?? list
|
||||
const box = menu.getBoundingClientRect()
|
||||
const trigger = anchor.getBoundingClientRect()
|
||||
const browser = frame.getBoundingClientRect()
|
||||
const left = Math.max(box.left, browser.left)
|
||||
const right = Math.min(box.right, browser.right)
|
||||
const top = Math.max(box.top, browser.top)
|
||||
const bottom = Math.min(box.bottom, browser.bottom)
|
||||
const overlapsBrowser = right - left > 8 && bottom - top > 8
|
||||
const aboveBrowser = overlapsBrowser && [0.25, 0.5, 0.75].every(x =>
|
||||
[0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint(
|
||||
left + (right - left) * x, top + (bottom - top) * y,
|
||||
))))
|
||||
const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12)
|
||||
const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12)
|
||||
return {
|
||||
dockMode: dock.dataset.sidebarRightPanel,
|
||||
bodyMounted: menu.parentElement === document.body,
|
||||
fixed: getComputedStyle(menu).position === 'fixed',
|
||||
anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1,
|
||||
viewportBounded: box.left >= 11 && box.top >= 11
|
||||
&& box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11,
|
||||
overlapsBrowser,
|
||||
aboveBrowser,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let agent: Agent
|
||||
let jobId: JobId | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
|
||||
})
|
||||
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.route('https://browser.test/**', async route => route.fulfill({
|
||||
contentType: 'text/html', body: '<h1>Jobs menu browser fixture</h1>',
|
||||
}))
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
if (scaffold !== undefined) {
|
||||
try {
|
||||
await writeFile(join(scaffold.workspaceCwd, RELEASE), '')
|
||||
if (jobId !== undefined) {
|
||||
const settled = await scaffold.ctx.jobs.wait(jobId, 15_000, agent.id)
|
||||
if (settled.status === 'running' || settled.status === 'stopping') {
|
||||
throw new Error('jobs menu fixture did not settle after releasing its barrier')
|
||||
}
|
||||
}
|
||||
} catch (error) { failures.push(error) }
|
||||
}
|
||||
try { await browser?.close() } catch (error) { failures.push(error) }
|
||||
try { await scaffold?.close() } catch (error) { failures.push(error) }
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'jobs menu layering teardown failed')
|
||||
})
|
||||
|
||||
it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering'))
|
||||
const session = page.locator(`[data-row-key="session:${SEED_ID}"]`)
|
||||
await expect.poll(async () => {
|
||||
if (await session.isVisible()) return true
|
||||
const group = page.locator('[role="treeitem"][aria-expanded]').first()
|
||||
if (await group.count() > 0 && await group.getAttribute('aria-expanded') === 'false') await group.click()
|
||||
return session.isVisible()
|
||||
}, { timeout: 15_000 }).toBe(true)
|
||||
await session.click()
|
||||
await expect.poll(() => scaffold.ctx.agents.get(SEED_ID), { timeout: 30_000 }).toBeDefined()
|
||||
const opened = scaffold.ctx.agents.get(SEED_ID)
|
||||
if (opened === undefined) throw new Error('opened session has no live Agent')
|
||||
agent = opened
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: ToolCallId('jobs-menu-layering-e2e'),
|
||||
name: 'bash',
|
||||
arguments: { command: COMMAND, description: 'Emit output and hold the jobs menu fixture', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
|
||||
const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported)
|
||||
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
|
||||
const startedJobId = JobId(matched[0])
|
||||
jobId = startedJobId
|
||||
// Registration precedes shell startup; inspect its output before depending on a live row.
|
||||
await expect.poll(() => {
|
||||
const job = scaffold.ctx.jobs.get(startedJobId, agent.id)
|
||||
const output = scaffold.ctx.jobs.readAt(startedJobId, 0, agent.id).chunks.map(chunk => chunk.text).join('')
|
||||
return job.status !== 'running' || output.includes('jobs-menu-output')
|
||||
}, { timeout: 15_000 }).toBe(true)
|
||||
const readyJob = scaffold.ctx.jobs.get(startedJobId, agent.id)
|
||||
const readyOutput = scaffold.ctx.jobs.readAt(startedJobId, 0, agent.id).chunks.map(chunk => chunk.text).join('')
|
||||
expect(readyJob.status, `${readyJob.detail ?? ''}\n${readyOutput}`).toBe('running')
|
||||
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
|
||||
const address = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
|
||||
await address.fill('https://browser.test/jobs-menu')
|
||||
await address.press('Enter')
|
||||
await page.frameLocator('[data-sidebar-browser-frame]')
|
||||
.getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor()
|
||||
|
||||
const trigger = page.getByRole('button', { name: '1 background job running', exact: true })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
const list = page.getByRole('list', { name: 'Background jobs' })
|
||||
await list.waitFor()
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await list.getByRole('button', { name: /Show live output/ })
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const expectedPlacement = {
|
||||
dockMode: 'push', bodyMounted: true, fixed: true, anchored: true,
|
||||
viewportBounded: true, overlapsBrowser: true, aboveBrowser: true,
|
||||
}
|
||||
await expect.poll(() => placement(page)).toEqual(expectedPlacement)
|
||||
const collapsed = await placement(page)
|
||||
|
||||
await list.getByRole('button', { name: /Show live output/ }).click()
|
||||
await list.getByText('jobs-menu-output', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => placement(page)).toEqual(expectedPlacement)
|
||||
const expanded = await placement(page)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 760 })
|
||||
await expect.poll(() => placement(page)).toEqual(expectedPlacement)
|
||||
const resized = await placement(page)
|
||||
await list.getByRole('button', { name: /Hide live output/ }).focus()
|
||||
await page.keyboard.press('Escape')
|
||||
await list.waitFor({ state: 'detached' })
|
||||
const escapeRestoresFocus = await trigger.evaluate(node => node === document.activeElement)
|
||||
expect(escapeRestoresFocus).toBe(true)
|
||||
|
||||
await trigger.click()
|
||||
await list.waitFor()
|
||||
await address.click()
|
||||
await list.waitFor({ state: 'detached' })
|
||||
const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false'
|
||||
expect(outsideDismisses).toBe(true)
|
||||
expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
await mkdir(EXPECTED_DIR, { recursive: true })
|
||||
await compareOrRefreshGolden(join(EXPECTED_DIR, 'geometry.expected.md'), [
|
||||
'# Background jobs above the Browser dock', '',
|
||||
'```json',
|
||||
JSON.stringify({ collapsed, expanded, resized, escapeRestoresFocus, outsideDismisses }, null, 2),
|
||||
'```',
|
||||
].join('\n'), MODE)
|
||||
await assertFixtureInventory(EXPECTED_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,141 @@ describe('web e2e: plugin manager', () => {
|
||||
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
|
||||
}
|
||||
|
||||
it('keeps plugin header actions on one line as the available width changes', async () => {
|
||||
const facts: string[] = []
|
||||
// A dedicated scaffold keeps the measured header deterministic: an install
|
||||
// task left by an earlier test swaps the toolbar's add control for the
|
||||
// view-installation button, which moves the wrap threshold.
|
||||
const headerScaffold = await launchWebScaffold()
|
||||
try {
|
||||
// Pin the extremes; at intermediate widths, the rendered font and locale
|
||||
// determine whether the two intrinsic items fit beside each other.
|
||||
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' }
|
||||
for (const language of ['en', 'zh'] as const) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE,
|
||||
reducedMotion: 'reduce',
|
||||
})
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const title = language === 'en' ? 'Plugins' : '插件'
|
||||
await probe.getByRole('button', { name: title, exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const header = panel.locator(':scope > header')
|
||||
await header.getByRole('heading', { name: title, exact: true }).waitFor()
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
|
||||
await expect.poll(() => add.isEnabled()).toBe(true)
|
||||
await add.focus()
|
||||
const measure = () => header.evaluate((element) => {
|
||||
const heading = element.firstElementChild!
|
||||
const actions = element.lastElementChild!
|
||||
const headingRect = heading.getBoundingClientRect()
|
||||
const actionRect = actions.getBoundingClientRect()
|
||||
const headerRect = element.getBoundingClientRect()
|
||||
const intrinsicHeading = heading.cloneNode(true) as HTMLElement
|
||||
Object.assign(intrinsicHeading.style, {
|
||||
position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none',
|
||||
})
|
||||
element.append(intrinsicHeading)
|
||||
let intrinsicHeadingWidth: number
|
||||
try {
|
||||
intrinsicHeadingWidth = intrinsicHeading.getBoundingClientRect().width
|
||||
} finally {
|
||||
intrinsicHeading.remove()
|
||||
}
|
||||
const headerStyle = getComputedStyle(element)
|
||||
const contentWidth = headerRect.width - Number.parseFloat(headerStyle.paddingLeft) - Number.parseFloat(headerStyle.paddingRight)
|
||||
const fitsBeside = intrinsicHeadingWidth + actionRect.width + Number.parseFloat(headerStyle.columnGap) <= contentWidth + 1
|
||||
const subtitle = heading.querySelector('h1 + div > span')!
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(subtitle)
|
||||
const lastText = Array.from(range.getClientRects()).at(-1)!
|
||||
const info = heading.querySelector('button')!.getBoundingClientRect()
|
||||
const buttons = Array.from(actions.querySelectorAll('button'))
|
||||
const singleLine = buttons.every((button) => {
|
||||
const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT)
|
||||
const tops: number[] = []
|
||||
while (walker.nextNode()) {
|
||||
if (!walker.currentNode.textContent?.trim()) continue
|
||||
range.selectNodeContents(walker.currentNode)
|
||||
for (const rect of range.getClientRects()) tops.push(rect.top)
|
||||
}
|
||||
return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1
|
||||
})
|
||||
return {
|
||||
singleLine, fitsBeside,
|
||||
diagnostic: { flexWrap: headerStyle.flexWrap, headingMaxWidth: getComputedStyle(heading).maxWidth,
|
||||
introDisplay: getComputedStyle(heading.querySelector('h1 + div')!).display,
|
||||
intrinsicHeadingWidth, headingWidth: headingRect.width, actionsWidth: actionRect.width,
|
||||
contentWidth, gap: Number.parseFloat(headerStyle.columnGap),
|
||||
subtitleRight: lastText.right, infoLeft: info.left,
|
||||
},
|
||||
placement: actionRect.left >= headingRect.right - 1
|
||||
? 'beside' as const
|
||||
: actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
|
||||
contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1,
|
||||
infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5
|
||||
&& info.top < lastText.bottom && info.bottom > lastText.top,
|
||||
focused: buttons.includes(document.activeElement as HTMLButtonElement),
|
||||
}
|
||||
})
|
||||
const validLayout = (layout: Awaited<ReturnType<typeof measure>>) =>
|
||||
layout.singleLine && layout.contained && layout.infoFollowsText && layout.focused
|
||||
&& layout.placement === (layout.fitsBeside ? 'beside' : 'below')
|
||||
for (const width of [1680, 768, 520, 440, 390, 430, 1024]) {
|
||||
await probe.setViewportSize({ width, height: 1000 })
|
||||
await expect.poll(async () => {
|
||||
const layout = await measure()
|
||||
return validLayout(layout) ? true : layout
|
||||
}, { message: `${language} ${width}px plugin header geometry` }).toBe(true)
|
||||
const { placement } = await measure()
|
||||
const pinned = PINNED_PLACEMENT[width]
|
||||
if (pinned !== undefined) expect(placement).toBe(pinned)
|
||||
facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`)
|
||||
}
|
||||
await probe.setViewportSize({ width: 390, height: 1000 })
|
||||
const previousWrap = await header.evaluate((element) => {
|
||||
const style = element.style
|
||||
const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') }
|
||||
style.setProperty('flex-wrap', 'nowrap')
|
||||
return previous
|
||||
})
|
||||
try {
|
||||
const noWrap = await measure()
|
||||
expect(noWrap.fitsBeside).toBe(false)
|
||||
expect(noWrap.placement).not.toBe('below')
|
||||
expect(validLayout(noWrap)).toBe(false)
|
||||
} finally {
|
||||
await header.evaluate((element, previous) => {
|
||||
if (previous.value === '') element.style.removeProperty('flex-wrap')
|
||||
else element.style.setProperty('flex-wrap', previous.value, previous.priority)
|
||||
}, previousWrap)
|
||||
}
|
||||
await expect.poll(async () => validLayout(await measure())).toBe(true)
|
||||
const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' })
|
||||
await menuTrigger.click()
|
||||
await probe.getByRole('menu').waitFor()
|
||||
await probe.keyboard.press('Escape')
|
||||
await expect.poll(() => probe.getByRole('menu').count()).toBe(0)
|
||||
expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(
|
||||
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
|
||||
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`,
|
||||
MODE,
|
||||
)
|
||||
} finally {
|
||||
await headerScaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
|
||||
const facts: string[] = []
|
||||
let aria = ''
|
||||
@@ -932,7 +1067,7 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
|
||||
'ime-enter.expected.md',
|
||||
'ime-enter.expected.md', 'header-responsive.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// UI-only geometry for the Automation tasks page; no model calls or task mutations.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
|
||||
it('keeps the automation title intact before moving New to another line', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const facts: string[] = []
|
||||
for (const language of ['en', 'zh'] as const) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1000 },
|
||||
locale: language === 'en' ? 'en-US' : 'zh-CN',
|
||||
reducedMotion: 'reduce',
|
||||
})
|
||||
try {
|
||||
const page = await context.newPage()
|
||||
const consoleWatch = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const title = language === 'en' ? 'Automation tasks' : '自动化任务'
|
||||
await page.getByRole('button', { name: title, exact: true }).click()
|
||||
const panel = page.getByTestId('task-manager-page')
|
||||
const heading = panel.getByRole('heading', { name: title, exact: true })
|
||||
await heading.waitFor()
|
||||
const header = heading.locator('..')
|
||||
const create = header.getByRole('button', { name: language === 'en' ? 'New' : '新建', exact: true })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await create.focus()
|
||||
for (const width of [1680, 800, 520, 440, 390, 430, 1024]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
// The title keeps its natural width at every viewport width, so New
|
||||
// stays beside it here; the squeeze case below is what moves it down.
|
||||
await expect.poll(() => header.evaluate((element) => {
|
||||
const title = element.querySelector('h1')!
|
||||
const button = element.querySelector('button')!
|
||||
const titleRect = title.getBoundingClientRect()
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const headerRect = element.getBoundingClientRect()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(title)
|
||||
const titleLines = Array.from(range.getClientRects())
|
||||
return {
|
||||
titleSingleLine: titleLines.length === 1,
|
||||
buttonSingleLine: button.scrollHeight <= button.clientHeight,
|
||||
placement: buttonRect.left >= titleRect.right - 1
|
||||
? 'beside' as const
|
||||
: buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
|
||||
contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1
|
||||
&& buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1,
|
||||
focused: document.activeElement === button,
|
||||
}
|
||||
})).toEqual({
|
||||
titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true,
|
||||
})
|
||||
facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`)
|
||||
}
|
||||
if (language === 'en') {
|
||||
// 509px collapses the left sidebar to its rail; expanding it leaves the
|
||||
// page column narrow enough (measured 172px) to force the wrap.
|
||||
await page.setViewportSize({ width: 509, height: 1000 })
|
||||
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
|
||||
await expect.poll(() => header.evaluate((element) => {
|
||||
const title = element.querySelector('h1')!
|
||||
const button = element.querySelector('button')!
|
||||
const titleRect = title.getBoundingClientRect()
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(title)
|
||||
return {
|
||||
titleSingleLine: range.getClientRects().length === 1,
|
||||
placement: buttonRect.left >= titleRect.right - 1
|
||||
? 'beside' as const
|
||||
: buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
|
||||
}
|
||||
})).toEqual({ titleSingleLine: true, placement: 'below' })
|
||||
facts.push('en 509px with sidebar expanded: title single-line; New moves below the title')
|
||||
}
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/schedule-header.expected.md', import.meta.url)),
|
||||
`# Automation tasks header\n\n${facts.join('\n')}`,
|
||||
webSnapshotMode(),
|
||||
)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
@@ -146,8 +146,11 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
await workspaceRow.getByText(workspaceLabel, { exact: true }).hover()
|
||||
await details.waitFor()
|
||||
await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover()
|
||||
await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor()
|
||||
await expect.poll(() => details.count()).toBe(0)
|
||||
const newSessionTooltip = page.getByRole('tooltip').filter({ hasText: 'New Session' })
|
||||
await newSessionTooltip.waitFor()
|
||||
expect(await details.isVisible()).toBe(true)
|
||||
expect(await newSessionTooltip.evaluate(element => Number(getComputedStyle(element).zIndex)))
|
||||
.toBeGreaterThan(await details.evaluate(element => Number(getComputedStyle(element).zIndex)))
|
||||
await workspaceRow.getByText(workspaceLabel, { exact: true }).hover()
|
||||
await details.waitFor()
|
||||
await page.mouse.move(700, 100)
|
||||
|
||||
@@ -634,19 +634,25 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
const write = handle.write.bind(handle)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const burst = `printf 'BUFFER_%s\\n' OK # ${'a'.repeat(3000)}`
|
||||
let delivered = ''
|
||||
const paused = vi.spyOn(handle, 'write').mockImplementation(async (data) => {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
await write(data)
|
||||
delivered += data
|
||||
})
|
||||
try {
|
||||
await page.locator('.xterm-helper-textarea:visible').click()
|
||||
await page.keyboard.type(`printf 'BUFFER_%s\\n' OK # ${'a'.repeat(3000)}`)
|
||||
await page.keyboard.type(burst)
|
||||
await page.keyboard.press('Enter')
|
||||
await entered.promise
|
||||
expect(paused).toHaveBeenCalledTimes(1)
|
||||
release.resolve(undefined)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('BUFFER_OK')
|
||||
// The concurrent browser lane needs separate completion waits for queued input and rendered output.
|
||||
await expect.poll(() => delivered.endsWith('\r'), { timeout: 30_000, message: 'The queued Enter must reach the PTY' }).toBe(true)
|
||||
expect(delivered).toBe(`${burst}\r`)
|
||||
await page.locator('.xterm-rows:visible').filter({ hasText: 'BUFFER_OK' }).waitFor({ timeout: 30_000 })
|
||||
expect(handles).toHaveLength(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
|
||||
@@ -507,21 +507,21 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
|
||||
* blank Session from a deleted Workspace may also be visible, without actions.
|
||||
* @returns the session row locator, already present.
|
||||
*/
|
||||
async function seededSessionRow() {
|
||||
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
async function seededSessionRow(target = page) {
|
||||
const ungroupedRow = target.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
const ungroupedSection = ungroupedRow.locator('..')
|
||||
// Initial-current auto-expansion can race this gesture; converge on
|
||||
// expanded rather than assuming which update wins first.
|
||||
await expect.poll(async () => {
|
||||
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
await page.waitForTimeout(50)
|
||||
await target.getByText('Ungrouped', { exact: true }).click()
|
||||
await target.waitForTimeout(50)
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
// CSS includes the actions button while it is hidden until row hover.
|
||||
const rows = ungroupedSection.locator('[role="treeitem"]')
|
||||
.filter({ has: page.locator('button[aria-label^="Session actions for "]') })
|
||||
.filter({ has: target.locator('button[aria-label^="Session actions for "]') })
|
||||
await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1)
|
||||
return rows.first()
|
||||
}
|
||||
@@ -561,6 +561,242 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps session details beside action tooltips with the tooltips above the card', async () => {
|
||||
const hoverPage = await newEnglishPage(browser)
|
||||
try {
|
||||
const consoleWatch = watchConsole(hoverPage)
|
||||
await hoverPage.goto(scaffold.authenticatedUrl)
|
||||
const row = await seededSessionRow(hoverPage)
|
||||
const title = row.locator('[class*="title"]')
|
||||
const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` })
|
||||
const tooltip = hoverPage.getByRole('tooltip')
|
||||
const observations: { stage: string; details: boolean; tooltips: string[] }[] = []
|
||||
const observe = async (stage: string): Promise<void> => {
|
||||
observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() })
|
||||
}
|
||||
const clockStart = Date.now()
|
||||
await hoverPage.clock.install({ time: clockStart })
|
||||
await hoverPage.clock.pauseAt(clockStart + 1000)
|
||||
await title.hover()
|
||||
await hoverPage.clock.runFor(800)
|
||||
await card.waitFor()
|
||||
await observe('details')
|
||||
await row.getByRole('button', { name: /^Session actions for / }).hover()
|
||||
await hoverPage.clock.runFor(1000)
|
||||
expect(await card.count()).toBe(1)
|
||||
await observe('more-hover')
|
||||
|
||||
await row.getByRole('button', { name: 'Archive session', exact: true }).hover()
|
||||
await hoverPage.clock.runFor(499)
|
||||
expect(await card.count()).toBe(1)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
await observe('archive-before-dwell')
|
||||
await hoverPage.clock.runFor(1)
|
||||
await tooltip.filter({ hasText: 'Archive' }).waitFor()
|
||||
expect(await card.count()).toBe(1)
|
||||
expect(await tooltip.evaluate(el => Number(getComputedStyle(el).zIndex)))
|
||||
.toBeGreaterThan(await card.evaluate(el => Number(getComputedStyle(el).zIndex)))
|
||||
await observe('archive')
|
||||
await row.getByRole('button', { name: 'Pin session', exact: true }).hover()
|
||||
await hoverPage.clock.runFor(500)
|
||||
await tooltip.filter({ hasText: 'Pin' }).waitFor()
|
||||
expect(await card.count()).toBe(1)
|
||||
await observe('pin')
|
||||
|
||||
await title.hover()
|
||||
await tooltip.waitFor({ state: 'detached' })
|
||||
// Advance beyond both the 800ms card dwell and the 500ms action tooltip dwell.
|
||||
await hoverPage.clock.runFor(1600)
|
||||
expect(await card.count()).toBe(1)
|
||||
await observe('back-to-title')
|
||||
|
||||
const menu = row.getByRole('button', { name: /^Session actions for / })
|
||||
await menu.click()
|
||||
await hoverPage.getByRole('menu').waitFor()
|
||||
expect(await card.count()).toBe(0)
|
||||
await hoverPage.keyboard.press('Escape')
|
||||
await hoverPage.getByRole('menu').waitFor({ state: 'detached' })
|
||||
await title.hover()
|
||||
await hoverPage.clock.runFor(1600)
|
||||
expect(await card.count()).toBe(0)
|
||||
await observe('menu-closed')
|
||||
|
||||
await hoverPage.getByRole('button', { name: 'Settings' }).hover()
|
||||
await title.hover()
|
||||
await hoverPage.clock.runFor(799)
|
||||
expect(await card.count()).toBe(0)
|
||||
await hoverPage.clock.runFor(1)
|
||||
await card.waitFor()
|
||||
await observe('row-reentered')
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/session-row-hover/actions.expected.json', import.meta.url)),
|
||||
JSON.stringify(observations, null, 2), MODE,
|
||||
)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await hoverPage.close()
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('shares one dwell and one visible card across Workspace and Session rows', async () => {
|
||||
const hoverScaffold = await launchWebScaffold({})
|
||||
let ownedPage: Page | undefined
|
||||
try {
|
||||
for (const title of ['gamma-ws', 'beta-ws']) {
|
||||
const cwd = join(hoverScaffold.workspaceCwd, title)
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await hoverScaffold.ctx.workspaceRegistry.create(cwd, title)
|
||||
}
|
||||
const sessionId = SessionId('mixed-hover-session')
|
||||
await seedSession(hoverScaffold, await readFile(SEED, 'utf8'), sessionId)
|
||||
await hoverScaffold.ctx.sessionController.rename({ sessionId, title: 'Mixed hover session' })
|
||||
const hoverPage = await newEnglishPage(browser)
|
||||
ownedPage = hoverPage
|
||||
const consoleWatch = watchConsole(hoverPage)
|
||||
await hoverPage.goto(hoverScaffold.authenticatedUrl)
|
||||
const session = await seededSessionRow(hoverPage)
|
||||
const sessionTitle = await session.locator('[class*="title"]').innerText()
|
||||
const workspace = (title: string) => hoverPage.locator('[data-row-key^="workspace:"]')
|
||||
.filter({ hasText: title }).locator('[class*="title"]')
|
||||
const first = workspace('gamma-ws')
|
||||
const second = workspace('beta-ws')
|
||||
const card = hoverPage.getByRole('button', { name: /^Copy: / })
|
||||
const observations: { stage: string; exclusive: boolean }[] = []
|
||||
const clockStart = Date.now()
|
||||
await hoverPage.clock.install({ time: clockStart })
|
||||
await hoverPage.clock.pauseAt(clockStart + 1000)
|
||||
await hoverPage.getByRole('button', { name: 'Settings' }).hover()
|
||||
await hoverPage.clock.runFor(300)
|
||||
await first.hover()
|
||||
await hoverPage.clock.runFor(799)
|
||||
expect(await card.count()).toBe(0)
|
||||
await hoverPage.clock.runFor(1)
|
||||
const record = async (stage: string, title: string): Promise<void> => {
|
||||
// The clock stays paused: wait for React's commit without advancing another hover dwell.
|
||||
await card.getByText(title, { exact: true }).waitFor()
|
||||
expect(await card.count()).toBe(1)
|
||||
expect(await card.getByText(title, { exact: true }).isVisible()).toBe(true)
|
||||
observations.push({ stage, exclusive: true })
|
||||
}
|
||||
await record('first-workspace-after-dwell', 'gamma-ws')
|
||||
await second.hover()
|
||||
await record('workspace-to-workspace-immediate', 'beta-ws')
|
||||
await session.locator('[class*="title"]').hover()
|
||||
await record('workspace-to-session-immediate', sessionTitle)
|
||||
await first.hover()
|
||||
await record('session-to-workspace-immediate', 'gamma-ws')
|
||||
await hoverPage.getByRole('button', { name: 'Settings' }).hover()
|
||||
await hoverPage.clock.runFor(300)
|
||||
expect(await card.count()).toBe(0)
|
||||
await second.hover()
|
||||
await hoverPage.clock.runFor(799)
|
||||
expect(await card.count()).toBe(0)
|
||||
await hoverPage.clock.runFor(1)
|
||||
await record('list-reentered-after-dwell', 'beta-ws')
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/session-row-hover/mixed-rows.expected.json', import.meta.url)),
|
||||
JSON.stringify(observations, null, 2), MODE,
|
||||
)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
try { await ownedPage?.close() } finally { await hoverScaffold.close() }
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('shares the first session dwell across each list and its active hover card', async () => {
|
||||
const hoverScaffold = await launchWebScaffold({})
|
||||
try {
|
||||
const seed = await readFile(SEED, 'utf8')
|
||||
await seedSession(hoverScaffold, seed, 'hover-first')
|
||||
await seedSession(hoverScaffold, seed, 'hover-second')
|
||||
await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-first'), title: 'Hover first' })
|
||||
await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-second'), title: 'Hover second' })
|
||||
for (const layout of ['In one list', 'Workspace Tree']) {
|
||||
// Each page owns a browser context, so layout preferences and paused clocks cannot leak between layouts.
|
||||
const ui = await newEnglishPage(browser)
|
||||
try {
|
||||
const consoleWatch = watchConsole(ui)
|
||||
await ui.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await ui.goto(hoverScaffold.authenticatedUrl)
|
||||
await ui.getByRole('button', { name: 'View options' }).click()
|
||||
await ui.getByRole('menuitem', { name: layout, exact: true }).click()
|
||||
if (layout !== 'In one list') {
|
||||
const header = ui.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
await expect.poll(async () => {
|
||||
if (await header.getAttribute('aria-expanded') !== 'true') await ui.getByText('Ungrouped', { exact: true }).click()
|
||||
return header.getAttribute('aria-expanded')
|
||||
}).toBe('true')
|
||||
}
|
||||
const first = ui.locator('[data-row-key="session:hover-first"]').getByText('Hover first', { exact: true })
|
||||
const second = ui.locator('[data-row-key="session:hover-second"]').getByText('Hover second', { exact: true })
|
||||
await first.waitFor()
|
||||
await second.waitFor()
|
||||
const firstCard = ui.getByRole('button', { name: 'Copy: Hover first', exact: true })
|
||||
const secondCard = ui.getByRole('button', { name: 'Copy: Hover second', exact: true })
|
||||
const observations: { stage: string; first: boolean; second: boolean }[] = []
|
||||
const observe = async (stage: string): Promise<void> => {
|
||||
observations.push({ stage, first: await firstCard.count() > 0, second: await secondCard.count() > 0 })
|
||||
}
|
||||
const clockStart = Date.now()
|
||||
await ui.clock.install({ time: clockStart })
|
||||
await ui.clock.pauseAt(clockStart + 1000)
|
||||
await first.hover()
|
||||
await ui.clock.runFor(799)
|
||||
expect(await firstCard.count()).toBe(0)
|
||||
await observe('first-before-dwell')
|
||||
await ui.clock.runFor(1)
|
||||
await firstCard.waitFor()
|
||||
await observe('first-shown')
|
||||
await second.hover()
|
||||
// Wait for React's commit with the clock paused: an immediate handoff must not need another dwell.
|
||||
await secondCard.waitFor()
|
||||
expect(await firstCard.count()).toBe(0)
|
||||
await observe('second-instant')
|
||||
await first.hover()
|
||||
await firstCard.waitFor()
|
||||
expect(await secondCard.count()).toBe(0)
|
||||
await observe('first-instant')
|
||||
|
||||
await firstCard.hover()
|
||||
await ui.clock.runFor(500)
|
||||
expect(await firstCard.isVisible()).toBe(true)
|
||||
await observe('readable-portal')
|
||||
await firstCard.click()
|
||||
await ui.getByRole('status').getByText('Copied', { exact: true }).waitFor()
|
||||
expect(await ui.evaluate(() => navigator.clipboard.readText())).toBe('Hover first')
|
||||
await observe('copied-portal')
|
||||
await second.hover()
|
||||
await secondCard.waitFor()
|
||||
expect(await firstCard.count()).toBe(0)
|
||||
await observe('portal-return-instant')
|
||||
|
||||
await secondCard.hover()
|
||||
await ui.getByRole('button', { name: 'Settings' }).hover()
|
||||
await ui.clock.runFor(300)
|
||||
await secondCard.waitFor({ state: 'detached' })
|
||||
expect(await firstCard.count()).toBe(0)
|
||||
await observe('outside-cooled')
|
||||
await first.hover()
|
||||
await ui.clock.runFor(799)
|
||||
expect(await firstCard.count()).toBe(0)
|
||||
await observe('list-reentry-before-dwell')
|
||||
await ui.clock.runFor(1)
|
||||
await firstCard.waitFor()
|
||||
await observe('list-reentry-shown')
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/session-row-hover/grouped.expected.json', import.meta.url)),
|
||||
JSON.stringify(observations, null, 2), MODE,
|
||||
)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await ui.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await hoverScaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
|
||||
const sessionRow = await seededSessionRow()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Blank New Session folding through the shipped Web composition. */
|
||||
/** Blank New Session folding and the session watermark through the shipped Web composition. */
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
@@ -107,7 +107,42 @@ describe('web e2e: blank New Session folding quota', () => {
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18)
|
||||
await sidebar.getByRole('button', { name: 'Show less' }).click()
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7)
|
||||
await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('raises the watermark to the open session, keeps it after leaving, and resets it on fold', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-session-watermark'))
|
||||
const sidebar = page.getByRole('tree', { name: 'Sessions' })
|
||||
const workspaceTitle = basename(scaffold.workspaceCwd)
|
||||
const sessions = () => sidebar.getByText('Untitled', { exact: true }).count()
|
||||
await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).click()
|
||||
await expect.poll(sessions).toBe(10)
|
||||
await sidebar.getByText('Untitled', { exact: true }).nth(8).click()
|
||||
await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click()
|
||||
await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click()
|
||||
await expect.poll(sessions).toBe(16)
|
||||
await sidebar.getByRole('button', { name: 'Show less' }).click()
|
||||
await expect.poll(sessions).toBe(9)
|
||||
await sidebar.getByRole('button', { name: 'Show 7 more sessions' }).waitFor()
|
||||
|
||||
const workspaceRow = sidebar.getByRole('treeitem').first()
|
||||
await workspaceRow.hover()
|
||||
await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click()
|
||||
await sidebar.getByText('New Session', { exact: true }).waitFor()
|
||||
expect(await sessions()).toBe(9)
|
||||
await workspaceRow.hover()
|
||||
await compareOrRefreshGolden(
|
||||
join(EXPECTED_DIR, 'watermark-kept.expected.md'),
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
await workspaceRow.click()
|
||||
await workspaceRow.click()
|
||||
await expect.poll(sessions).toBe(5)
|
||||
await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).waitFor()
|
||||
await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md', 'watermark-kept.expected.md'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
@@ -75,9 +75,11 @@
|
||||
"tests/excel-opc.e2e.ts",
|
||||
"tests/office-fixture.ts",
|
||||
"tests/sidebar-browser.e2e.ts",
|
||||
"tests/jobs-menu-layering.e2e.ts",
|
||||
"tests/plugin-config.e2e.ts",
|
||||
"tests/input-plugin-extensions.e2e.ts",
|
||||
"tests/plugin-manager.e2e.ts",
|
||||
"tests/schedule-header.e2e.ts",
|
||||
"tests/worktree-opt-in.e2e.ts",
|
||||
"tests/session-inspector.e2e.ts",
|
||||
"tests/shared-inspector.e2e.ts",
|
||||
|
||||
@@ -3077,7 +3077,7 @@ export interface Config {
|
||||
```ts config-catalog
|
||||
/** Office skill resource location. */
|
||||
export interface Config {
|
||||
/** Absolute assets directory containing skill folders and shared scripts; defaults to packaged assets. */
|
||||
/** Absolute assets directory containing skill folders; defaults to packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
|
||||
@@ -3079,7 +3079,7 @@ export interface Config {
|
||||
```ts config-catalog
|
||||
/** Office skill resource location. */
|
||||
export interface Config {
|
||||
/** Absolute assets directory containing skill folders and shared scripts; defaults to packaged assets. */
|
||||
/** Absolute assets directory containing skill folders; defaults to packaged assets. */
|
||||
assetRoot?: string
|
||||
/** Standalone Node executable; defaults to the current executable outside Electron and SEA. */
|
||||
node?: string
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: c216e2df128b8cb9
|
||||
zh: c039cf84b0d86ad8
|
||||
/conversation-assembly/definition-and-typed-chat-payload:
|
||||
en: 5f24557a050d1df0
|
||||
zh: 47735627f802b6fa
|
||||
en: d8f5e4773704c718
|
||||
zh: f3db8c0d01080dba
|
||||
/conversation-assembly/predecessor-reads:
|
||||
en: da849b3b5da81da8
|
||||
zh: 37a647a439bc24b1
|
||||
|
||||
@@ -247,10 +247,24 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
`ConversationNodeDefinitionInput` accepts either this function or a read-only table from event type to matching function. Function-form registrations receive every event; table registrations receive only their listed types. The Registry copies table entries and precomputes ordered candidate Sets when registrations change, keeping candidate construction out of per-event dispatch. Replace a table by unregistering and registering again. Resolved `ConversationNodeDefinition` values retain a callable `match(event)` in both cases.
|
||||
|
||||
A match result with `exclusive: true` claims that event on the Definition's target. The assembler evaluates every match of the event before accepting any, so the outcome is independent of registration order: other Definitions of the claimed target build no Context for the event, and the target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. Two claims on one target, or a claim from a Definition without a target, throw during dispatch; a claim returned by the fallback itself has no effect. A business Definition uses a claim to present one kind of message that a generic Definition of the same target would otherwise also present, such as a late question reply.
|
||||
|
||||
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
|
||||
|
||||
`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
|
||||
|
||||
A Chat Node also chooses where its row stands through `groupTree`. Positions are ordered from the outermost inward, and a Node at a position also has the effect of every inner one. Chat process grouping, whole-Turn folding, and Turn-input detection read only this position; [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts) holds the complete contract.
|
||||
|
||||
| The Node is | Declare |
|
||||
|---|---|
|
||||
| A person's input claimed in Step 1; its Turn opens an exchange | `exchange` |
|
||||
| Another input of its Turn, such as later human input or a trigger notice; before the process it is the opening input, after it the whole Turn stays open | `input` |
|
||||
| A Turn notice that stays outside whole-Turn folding, such as an error or the completed-turn footer | `turn` |
|
||||
| An independent row that whole-Turn folding still hides, such as a model retry | `process` |
|
||||
| Ordinary process content that joins the surrounding step run | omit `groupTree` |
|
||||
|
||||
The [Chat business rules](../../packages/client/ui-chat/src/client/conversation-nodes/README.md#process-grouping) list the positions the built-in Nodes declare. When a generic Definition of the same target also matches the event your Node presents, claim the event with `exclusive: true` as described above.
|
||||
|
||||
## Predecessor reads
|
||||
|
||||
Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous<State>(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data.
|
||||
|
||||
@@ -249,10 +249,24 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
`ConversationNodeDefinitionInput` 接受上述函数,或事件类型到匹配函数的只读表。函数形式的注册接收所有事件;表形式只接收列出的类型。Registry 在注册关系变化时复制表条目并预计算有序候选 Set,不在逐事件分发时构造候选集合。替换表时先注销,再重新注册。两种形式解析后的 `ConversationNodeDefinition` 都保留可调用的 `match(event)`。
|
||||
|
||||
match 结果带 `exclusive: true` 时,即在该 Definition 的 target 上认领这个事件。Assembler 先求出该事件的全部 match,再逐个接收,因此结果与注册顺序无关:被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在分发时抛错;fallback 自身返回的认领不起作用。业务 Definition 用认领来呈现某一类消息,而同一 target 上的通用 Definition 原本也会呈现它,例如迟到的问题回答。
|
||||
|
||||
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
|
||||
|
||||
`target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。
|
||||
|
||||
Chat Node 还通过 `groupTree` 选择所在行的位置。位置由外到内排列,站在某个位置的 Node 同时具有所有内层位置的效果。Chat 的过程分组、整轮折叠和轮次输入判断只读取这个位置;完整契约见 [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts)。
|
||||
|
||||
| Node 是 | 声明 |
|
||||
|---|---|
|
||||
| Step 1 认领的人的输入,所在轮次开启一个交互段 | `exchange` |
|
||||
| 所在轮次的其他输入,如之后的人的输入或触发通知;位于过程之前时是开场输入,之后出现时整个轮次保持展开 | `input` |
|
||||
| 留在整轮折叠之外的轮次通知,如错误或已完成轮次页脚 | `turn` |
|
||||
| 整轮折叠仍会隐藏的独立行,如模型重试 | `process` |
|
||||
| 并入相邻步骤组的普通过程内容 | 不声明 `groupTree` |
|
||||
|
||||
各内置 Node 声明的位置见 [Chat 业务规则](../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#process-grouping)。如果同一 target 的通用 Definition 也会匹配你的 Node 所呈现的事件,按上文用 `exclusive: true` 认领该事件。
|
||||
|
||||
## Predecessor read
|
||||
|
||||
有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous<State>(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 8725fa62f36364d7
|
||||
zh: 0f2ff4bdf13ff55b
|
||||
/typert-remote-calls/invocation-descriptors:
|
||||
en: 6417ffb792fd5385
|
||||
zh: c012707075a0b21f
|
||||
en: 21e88d68154c2c92
|
||||
zh: 8c9e05c65ef0f34d
|
||||
/typert-remote-calls/typert-registry:
|
||||
en: 29f578ba733cfd5a
|
||||
zh: c486ff4ac2d1e3b4
|
||||
@@ -18,5 +18,5 @@
|
||||
en: 90fab88fe51d17bb
|
||||
zh: 2d4c3849cf2dc7d3
|
||||
/typert-remote-calls/consumer-remote:
|
||||
en: 4836f8f44fef8fd3
|
||||
zh: 51f3ab2462a08ff4
|
||||
en: 324386c600a5fdf5
|
||||
zh: 7963501eb663e228
|
||||
@@ -40,27 +40,31 @@ interface TypertLookupDefinition {
|
||||
|
||||
An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`.
|
||||
|
||||
`createTypertRemoteInfo` returns a lazy class instance implementing `TypertRemoteInfo`, which extends the mountable `TypertRemoteContribution`. Its `methods` index aliases the direct objects in `descriptors`: each `TypertRemoteMethod` adds named `input(options?)` and `invoke`, retains documentation on the method and its parameters, and uses `result` as its only output codec. Unary `mode` is absent; stream `mode` is `'stream'`. Context-only receivers remain in descriptors and scoped declarations. Registry queries return base descriptors; `isTypertRemoteMethod` narrows generated direct capabilities without recovering endpoint-specific generics.
|
||||
|
||||
`TypertSchema` guarantees parsing, not arbitrary Zod operations, although the raw factory spec requires Zod factories. Missing documentation or an unsupported optional JSON Schema projection does not invalidate an ordinary method. The info index and each input-policy view, parser, and derived projection are independently lazy and cached; no metadata or schema access invokes the network. The [protocol reference](<../../packages/typert/protocol/README.md>) defines these APIs, and the [generator reference](<../../packages/typert/generator/README.md#ordinary-remote-metadata>) defines their separate Host `./typert`, consumer `./remote`, and Client `./client/typert` artifacts.
|
||||
|
||||
`TypertRemoteSpec` holds a package and endpoint-keyed `TypertRemoteMethodSpec` values. Each ordered `TypertRemoteParameterSpec` contains normal `codec` and `strictCodec` factories from the same checker-resolved type model; the runtime derives identities, wire-name defaults, and named-call views. `TypertRemoteInputOptions` has only `strict?: boolean`; `method.input(options?)` selects a cached `TypertRemoteValue<T>` with matching parsing and optional documentation/projection, without changing Host dispatch or `result`. `RemoteDecoded<T>` describes carrier-reconstructed unary results without narrowing arbitrary parser outputs.
|
||||
|
||||
```ts type-equiv
|
||||
/** Codec attached to one invocation parameter or result. */
|
||||
type TypertCodec =
|
||||
| {
|
||||
type TypertCodec<Value = unknown> =
|
||||
| TypertRemoteValue<Value> & {
|
||||
readonly mode: 'strict'
|
||||
readonly typeSymbol: string
|
||||
/** Materialize and return the process-realm schema on first boundary use. */
|
||||
readonly create: () => TypertSchema
|
||||
/**
|
||||
* Decode a unary result whose fields require type-specific handling.
|
||||
* @param value - result reconstructed by the RPC carrier.
|
||||
* @returns the validated result, retaining native byte views.
|
||||
*/
|
||||
readonly decode?: (value: unknown) => unknown
|
||||
readonly decode?: ((value: unknown) => unknown) | undefined
|
||||
/**
|
||||
* Project typed binary fields into RPC result attachments.
|
||||
* @param value - native unary result.
|
||||
* @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder.
|
||||
* @returns JSON metadata with untouched JSON subtrees retained.
|
||||
*/
|
||||
readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown
|
||||
readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined
|
||||
}
|
||||
| {
|
||||
readonly mode: 'src-json'
|
||||
@@ -72,6 +76,8 @@ type TypertCodec =
|
||||
interface InvocationParameterDescriptor {
|
||||
/** Source-level parameter name. */
|
||||
readonly name: string
|
||||
/** Source parameter documentation, independent of JSON Schema support. */
|
||||
readonly description?: string
|
||||
/** Required key in the wire `args` object. */
|
||||
readonly wire: string
|
||||
/** Whether the value is JSON or requires a registered Host lookup. */
|
||||
@@ -135,6 +141,8 @@ interface InvocationDescriptor {
|
||||
}
|
||||
/** Codec for the unary result or each yielded stream item. */
|
||||
readonly result: TypertCodec
|
||||
/** Source-owned method documentation; ordinary methods need not have JSDoc. */
|
||||
readonly description?: string
|
||||
/** Source declaration used only for diagnostics. */
|
||||
readonly sourceLocation?: InvocationSourceLocation
|
||||
}
|
||||
@@ -364,6 +372,8 @@ interface TypertGateway {
|
||||
|
||||
`ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.<namespace>` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer.
|
||||
|
||||
Metadata ownership is separate from connection ownership; the [registry reference](<../../packages/typert/registry/README.md>) covers offline registration and mount lifetimes.
|
||||
|
||||
```ts type-equiv
|
||||
/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */
|
||||
interface TypertClientRemote extends TypertRemoteNamespaceMap {
|
||||
|
||||
@@ -40,27 +40,31 @@ interface TypertLookupDefinition {
|
||||
|
||||
`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。
|
||||
|
||||
`createTypertRemoteInfo` 返回实现 `TypertRemoteInfo` 的惰性类实例,后者扩展可挂载的 `TypertRemoteContribution`。其 `methods` 索引引用 `descriptors` 中的直接方法对象:每个 `TypertRemoteMethod` 增加具名 `input(options?)` 和 `invoke`,在方法及参数上保留文档,并以 `result` 作为唯一输出 codec。一元 `mode` 缺省;流式 `mode` 为 `'stream'`。仅使用 Context 接收器的方法保留在描述符和作用域声明中。Registry 查询返回基础描述符;`isTypertRemoteMethod` 收窄生成的直接调用能力,但不会恢复各端点的精确泛型。
|
||||
|
||||
`TypertSchema` 保证解析能力,不保证任意 Zod 操作,尽管原始 factory spec 要求 Zod factory。缺少文档或不支持可选的 JSON Schema 投影,不会使普通方法失效。info 索引与每种输入策略的视图、解析器和派生投影都独立惰性求值并缓存;读取元数据或 schema 绝不发起网络调用。[协议参考](<../../packages/typert/protocol/README.zh.md>)定义这些 API,[生成器参考](<../../packages/typert/generator/README.zh.md#ordinary-remote-metadata>)定义它们彼此分离的 Host `./typert`、消费方 `./remote` 和 Client `./client/typert` 产物。
|
||||
|
||||
`TypertRemoteSpec` 保存包名与按端点键控的 `TypertRemoteMethodSpec`。每个有序 `TypertRemoteParameterSpec` 条目都包含从同一 checker 解析后类型模型生成的普通 `codec` 和 `strictCodec` factory;运行时派生标识、默认 wire 名称和具名调用视图。`TypertRemoteInputOptions` 仅包含 `strict?: boolean`;`method.input(options?)` 选择缓存的 `TypertRemoteValue<T>`,提供匹配的解析及可选文档/投影,而不改变 Host 分发或 `result`。`RemoteDecoded<T>` 则描述 carrier 重建的一元结果,不会收窄任意解析器输出。
|
||||
|
||||
```ts type-equiv
|
||||
/** Codec attached to one invocation parameter or result. */
|
||||
type TypertCodec =
|
||||
| {
|
||||
type TypertCodec<Value = unknown> =
|
||||
| TypertRemoteValue<Value> & {
|
||||
readonly mode: 'strict'
|
||||
readonly typeSymbol: string
|
||||
/** Materialize and return the process-realm schema on first boundary use. */
|
||||
readonly create: () => TypertSchema
|
||||
/**
|
||||
* Decode a unary result whose fields require type-specific handling.
|
||||
* @param value - result reconstructed by the RPC carrier.
|
||||
* @returns the validated result, retaining native byte views.
|
||||
*/
|
||||
readonly decode?: (value: unknown) => unknown
|
||||
readonly decode?: ((value: unknown) => unknown) | undefined
|
||||
/**
|
||||
* Project typed binary fields into RPC result attachments.
|
||||
* @param value - native unary result.
|
||||
* @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder.
|
||||
* @returns JSON metadata with untouched JSON subtrees retained.
|
||||
*/
|
||||
readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown
|
||||
readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined
|
||||
}
|
||||
| {
|
||||
readonly mode: 'src-json'
|
||||
@@ -72,6 +76,8 @@ type TypertCodec =
|
||||
interface InvocationParameterDescriptor {
|
||||
/** Source-level parameter name. */
|
||||
readonly name: string
|
||||
/** Source parameter documentation, independent of JSON Schema support. */
|
||||
readonly description?: string
|
||||
/** Required key in the wire `args` object. */
|
||||
readonly wire: string
|
||||
/** Whether the value is JSON or requires a registered Host lookup. */
|
||||
@@ -135,6 +141,8 @@ interface InvocationDescriptor {
|
||||
}
|
||||
/** Codec for the unary result or each yielded stream item. */
|
||||
readonly result: TypertCodec
|
||||
/** Source-owned method documentation; ordinary methods need not have JSDoc. */
|
||||
readonly description?: string
|
||||
/** Source declaration used only for diagnostics. */
|
||||
readonly sourceLocation?: InvocationSourceLocation
|
||||
}
|
||||
@@ -364,6 +372,8 @@ interface TypertGateway {
|
||||
|
||||
`ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.<namespace>` Cordis 子服务,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务服务类型都不会进入消费方。
|
||||
|
||||
元数据所有权与连接所有权相互独立;[注册表参考](<../../packages/typert/registry/README.zh.md>)说明离线注册与挂载生命周期。
|
||||
|
||||
```ts type-equiv
|
||||
/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */
|
||||
interface TypertClientRemote extends TypertRemoteNamespaceMap {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md
|
||||
/:
|
||||
en: 51d5b5b4d2fb1b8a
|
||||
zh: 4dc9af2458a8fcac
|
||||
/azure-provider-route-rename:
|
||||
en: 105c73820d057e28
|
||||
zh: 91a7bfc5c282e164
|
||||
/azure-provider-route-rename/change:
|
||||
en: d5ce9089d8aa7119
|
||||
zh: f337926f85b01bc1
|
||||
/azure-provider-route-rename/migration:
|
||||
en: 8fb23fbb8184fa00
|
||||
zh: 8c44fb293a8dd70a
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The pi-ai Azure provider route is renamed from azure-openai-responses to azure."
|
||||
---
|
||||
|
||||
# Azure provider route rename
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The installed pi-ai catalog identifies the Azure provider as `azure` instead of `azure-openai-responses`. The `azure` route includes both Responses and Chat Completions models. The Responses API identifier remains `azure-openai-responses`, and the `AZURE_OPENAI_*` environment variables keep their names. Configurations and model selections that use the old provider key no longer resolve the Azure catalog.
|
||||
|
||||
## Migration
|
||||
|
||||
1. In the `llm-pi-ai` configuration in `$DSH_HOME/profiles/<profile>/cordis.patch.yml` or another supplying layer, rename `config.providers.azure-openai-responses` to `config.providers.azure`. Preserve its existing fields and any `apiKeyEnv` reference.
|
||||
2. Use catalog model IDs under `models`; map a custom Azure deployment name with `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. The mixed catalog cannot infer a protocol for a model ID absent from the catalog. An explicit route-level `api` overrides every model on that route; it does not select the Azure provider.
|
||||
3. Update explicit request/default provider selections to `azure`, and reselect an Azure model when continuing an existing Session. Configure the new Azure entry in Settings → Models if its key was stored for the old entry. Do not edit historical Session events.
|
||||
4. Confirm that Settings → Models offers `azure` and lists the expected models, then send a short request using the configured deployment.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "pi-ai 的 Azure 提供方路由从 azure-openai-responses 更名为 azure。"
|
||||
---
|
||||
|
||||
# Azure 提供方路由更名
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
已安装的 pi-ai 目录使用 `azure` 标识 Azure 提供方,替代 `azure-openai-responses`。`azure` 路由同时包含 Responses 和 Chat Completions 模型。Responses API 标识仍为 `azure-openai-responses`,`AZURE_OPENAI_*` 环境变量名称也保持不变。使用旧提供方键的配置和模型选择无法再解析 Azure 目录。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 或其他提供配置的层中,找到 `llm-pi-ai` 配置,将 `config.providers.azure-openai-responses` 改为 `config.providers.azure`。保留原有字段及任何 `apiKeyEnv` 引用。
|
||||
2. 在 `models` 中使用目录模型 ID;自定义 Azure 部署名称通过 `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` 映射。混合协议目录无法为目录外的模型 ID 推断协议。显式设置路由级 `api` 会覆盖该路由所有模型的协议,并不表示选择 Azure 提供方。
|
||||
3. 将显式请求及默认模型配置中的提供方改为 `azure`,继续现有 Session 时重新选择 Azure 模型。若密钥存储在旧条目下,请在“设置 → 模型”中配置新的 Azure 条目。不要修改历史 Session 事件。
|
||||
4. 确认“设置 → 模型”提供 `azure` 且列出预期模型,再向所配置的部署发送一条简短请求。
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry'
|
||||
import TypertGateway from '../src/index.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) })
|
||||
|
||||
const contribution: TypertContribution = {
|
||||
package: '@fixture/strict', face: 'host', schemas: [],
|
||||
model: { services: [], events: [], objects: [] },
|
||||
invocations: [{
|
||||
id: '@fixture/strict#items/read', service: 'items', namespace: 'items', method: 'read',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{ name: 'request', wire: 'request', source: 'json', codec: {
|
||||
mode: 'strict', typeSymbol: 'Request', create: () => z.strictObject({ title: z.string() }),
|
||||
} }],
|
||||
result: { mode: 'strict', typeSymbol: 'string', create: () => z.string() },
|
||||
}],
|
||||
}
|
||||
|
||||
class ItemsService extends Service {
|
||||
readonly typertRemote
|
||||
calls = 0
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'items')
|
||||
this.typertRemote = bindTypertRemote(this, 'items')
|
||||
}
|
||||
@Remote
|
||||
read(request: { title: string }): string {
|
||||
this.calls++
|
||||
return request.title
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGateway)
|
||||
const service = new ItemsService(ctx)
|
||||
return { ctx, service }
|
||||
}
|
||||
|
||||
it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
ctx.typert.register(contribution)
|
||||
await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } }))
|
||||
.resolves.toBe('ready')
|
||||
for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) {
|
||||
const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/)
|
||||
await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args }))
|
||||
.rejects.toMatchObject({ code })
|
||||
}
|
||||
expect(service.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('allows limited source fallback without recovering erased object constraints', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } } }))
|
||||
.resolves.toBe('ready')
|
||||
expect(service.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses source fallback after the strict definition owner unloads', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const owner = ctx.plugin({ inject: ['typert'], apply(ctx) { ctx.typert.register(contribution) } })
|
||||
await owner
|
||||
expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0])
|
||||
await owner.dispose()
|
||||
expect(ctx.typert.local.get('items/read')).toBeUndefined()
|
||||
await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } }))
|
||||
.rejects.toMatchObject({ code: 'gateway/definition-unavailable' })
|
||||
expect(service.calls).toBe(0)
|
||||
})
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 4cd7c81d70d2f365
|
||||
zh: 18eca6d16e44b6c2
|
||||
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
|
||||
en: bc8d1d786c9affea
|
||||
zh: 3139f0228527a174
|
||||
en: 81d7ca7595c34d39
|
||||
zh: 29eb8248c75e9ea6
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 3f4e6c161b4583bb
|
||||
zh: e4265bb2bce8eb1c
|
||||
|
||||
@@ -103,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea
|
||||
|
||||
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
|
||||
|
||||
The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder.
|
||||
The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. The positions, from outermost inward `exchange`, `input`, `turn`, and `process`, are defined in [group-tree.ts](src/client/contract/group-tree.ts); process grouping, whole-Turn folding, and Turn-input detection read them, so an external Definition places its Chat rows by declaring one. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder.
|
||||
|
||||
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration.
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
|
||||
|
||||
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
|
||||
|
||||
Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。
|
||||
Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。这些位置由外到内依次为 `exchange`、`input`、`turn`、`process`,定义在 [group-tree.ts](src/client/contract/group-tree.ts);过程分组、整轮折叠和轮次输入判断都读取它们,外部 Definition 声明位置即可放置自己的 Chat 行。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。
|
||||
|
||||
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@de
|
||||
import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatFlowSlotProps, UsePresentation } from '../contract/slots.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatNodeStore } from '../contract/snapshot.ts'
|
||||
import { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts'
|
||||
import { isAtOrOutside } from '../contract/group-tree.ts'
|
||||
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
|
||||
import { turnProcessOpen } from '../stores.ts'
|
||||
import { hiddenOrCollapsing } from './flow-motion.ts'
|
||||
import css from './ChatView.module.css'
|
||||
@@ -76,7 +77,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
&& (processPresentation.turnStarted || processPresentation.turnClosed)
|
||||
const processMember = routedNode !== undefined
|
||||
&& processWindowReady
|
||||
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
|
||||
&& !isAtOrOutside(routedNode, 'turn')
|
||||
&& routedNode.anchorSeq >= processSpec.processStartSeq
|
||||
&& (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq
|
||||
|| (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep))
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/
|
||||
import {
|
||||
Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { isAtOrOutside } from '../contract/group-tree.ts'
|
||||
import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
@@ -37,13 +38,12 @@ function observedInputs(
|
||||
let lastInputTurn: number | undefined
|
||||
for (const key of order) {
|
||||
const node = nodes.get(key)
|
||||
if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) continue
|
||||
if (node === undefined || !isAtOrOutside(node, 'input')) continue
|
||||
if (node.location.kind === 'turn' || node.location.kind === 'step') lastInputTurn = node.location.turn.turn
|
||||
if (node.kind === 'turn-trigger') 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)
|
||||
const data: unknown = node.data
|
||||
const source = typeof data === 'object' && data !== null && 'source' in data ? data.source : undefined
|
||||
if (typeof source === 'object' && source !== null && 'kind' in source && source.kind === 'user'
|
||||
&& 'rpcId' in source && typeof source.rpcId === 'string') observed.add(source.rpcId)
|
||||
}
|
||||
return { rpcIds: observed, lastInputTurn }
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export interface ChatConversationViewNode extends ConversationViewNode {
|
||||
readonly location: ConversationLocation
|
||||
readonly visibility: 'visible' | 'hidden'
|
||||
/**
|
||||
* Group-tree position, visible or hidden. An `exchange` Node's Turn and every
|
||||
* later Turn before the next opening Turn form one exchange; the latest
|
||||
* loaded exchange starts at the last loaded opening Turn, or at the first
|
||||
* loaded Turn when none is loaded.
|
||||
* Group-tree position, visible or hidden; absence joins the surrounding step
|
||||
* run. An `exchange` Node's Turn and every later Turn before the next opening
|
||||
* Turn form one exchange; the latest loaded exchange starts at the last loaded
|
||||
* opening Turn, or at the first loaded Turn when none is loaded.
|
||||
*/
|
||||
readonly groupTree?: ChatGroupTree | undefined
|
||||
}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import type { ChatConversationViewNode } from './chat-nodes.ts'
|
||||
|
||||
/**
|
||||
* Chat group-tree positions from the outermost grouping inward; a Node at a
|
||||
* position also stands outside every inner grouping.
|
||||
* Chat group-tree positions from the outermost inward; a Node at a position
|
||||
* also has the effect of every inner position.
|
||||
* - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts
|
||||
* until the next opening Turn.
|
||||
* - `input`: the Node is an input of its Turn. An input before the Turn's
|
||||
* process is the Turn's opening input; a later one is interleaved and keeps
|
||||
* the whole Turn open.
|
||||
* - `turn`: the Node stays in its Turn outside the Turn's process disclosure.
|
||||
* - `process`: the Node stays in the process disclosure outside step runs.
|
||||
* - absent: the Node joins the step run around it.
|
||||
*
|
||||
* Only `exchange` is read today; process disclosure and step runs still select
|
||||
* independent Nodes by kind (`TURN_PROCESS_INDEPENDENT_KINDS`, the process-group
|
||||
* `INDEPENDENT` set), so no Definition publishes `turn` or `process` yet.
|
||||
* The Definition that builds a Node chooses its position, and step runs, the
|
||||
* process disclosure, and Turn inputs are selected by position. An
|
||||
* `assistant-step` Node places its rows without one: its reasoning row joins
|
||||
* the step run and its reply row stands outside it.
|
||||
*
|
||||
* Whole-Turn folding also exempts the finalized answer's reply. That exemption
|
||||
* comes from the Turn's process specification (`answerAnchorSeq` and
|
||||
* `answerStep`), not from a position, because which step answers changes as
|
||||
* later steps arrive.
|
||||
*/
|
||||
export type ChatGroupTree = 'exchange' | 'turn' | 'process'
|
||||
export type ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'
|
||||
|
||||
const DEPTH: Readonly<Record<ChatGroupTree, number>> = { exchange: 0, turn: 1, process: 2 }
|
||||
const DEPTH: Readonly<Record<ChatGroupTree, number>> = { exchange: 0, input: 1, turn: 2, process: 3 }
|
||||
|
||||
/**
|
||||
* Test whether a Node stands at a group-tree position or outside it.
|
||||
|
||||
@@ -17,22 +17,6 @@ export interface TurnProcessSpec {
|
||||
readonly subagentCount: number
|
||||
}
|
||||
|
||||
const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
|
||||
'system-prompt',
|
||||
'user',
|
||||
'steering',
|
||||
'turn-trigger',
|
||||
'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<string> = new Set(
|
||||
TURN_PROCESS_INDEPENDENT_KIND_LIST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Compare immutable Turn-process specifications by their published fields.
|
||||
* @param left - previous specification.
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 91b35cbebe8c2a1d
|
||||
zh: a4fee6d7102bc874
|
||||
/chat-conversation-node-rules/whole-turn-folding:
|
||||
en: 8e0c0b62da18a24f
|
||||
zh: f740809d0b7ff2f5
|
||||
en: 42c6f6b9e2433263
|
||||
zh: 651ceb5c22314259
|
||||
/chat-conversation-node-rules/display-modes:
|
||||
en: ada5a60d2c46f6a8
|
||||
zh: f3eaeb5cb4d9b3de
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 2392290662bc69e8
|
||||
zh: 8872d651c07a8f4c
|
||||
/chat-conversation-node-rules/process-grouping:
|
||||
en: c2650489ded57503
|
||||
zh: f2554d74e75ae0f9
|
||||
en: 3d28b3bb8757bf55
|
||||
zh: 3b0f451b48db6048
|
||||
/chat-conversation-node-rules/process-grouping/examples:
|
||||
en: 2a550f7e04bce4b0
|
||||
zh: b8f95b687448cd77
|
||||
|
||||
@@ -94,7 +94,7 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`
|
||||
| Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. |
|
||||
| A Turn control exists, but there is no process content | Retain its title, without a collapse action. |
|
||||
|
||||
The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. User and steering inputs, trigger notices, terminal errors, max-token notices, and the completed-turn footer remain independent. Secondary grouping treats model retries as separators, but whole-Turn folding still includes retry rows.
|
||||
The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it.
|
||||
|
||||
Loading an older page preserves the reader's group-opening choices. Newly loaded process content follows the same Turn state while the final answer is unchanged and whole-Turn folding remains eligible. If the page reveals an intervening input, individual group disclosures replace whole-Turn hiding. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group.
|
||||
|
||||
@@ -198,15 +198,14 @@ Switching modes retains manually opened groups and inner disclosures. It changes
|
||||
|
||||
A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed.
|
||||
|
||||
When `question-reply` and `turn-trigger` project the same message id, grouping references only the question reply. Both Nodes stay in the Node Store; the duplicate trigger neither renders nor splits a group. The Turn control precedes the group containing an opening reply. This aggregation also applies before the owning Turn is loaded.
|
||||
Grouping reads each Node's [group-tree position](../contract/group-tree.ts), which its Definition sets, rather than its kind.
|
||||
|
||||
| Input | Membership and segmentation |
|
||||
|---|---|
|
||||
| Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. |
|
||||
| Assistant reply | End the preceding group and emit an independent `response` reference. If the same Node has reasoning, append that reasoning before ending the group. |
|
||||
| `user`, `steering`, `turn-trigger`, `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail` | End the preceding group and retain the Node as an independent root. |
|
||||
| `turn-process` | Retain the control as an independent root without ending the current group. |
|
||||
| Other visible Nodes owned by a Turn, including tools | Append the whole Node to the current group, creating a group if necessary. |
|
||||
| Node at `process` or an outer position: `model-retry`, Turn inputs, `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control | End the preceding group and retain the Node as an independent root. |
|
||||
| Other visible Nodes owned by a Turn without a position, including tools | Append the whole Node to the current group, creating a group if necessary. |
|
||||
| Node from another Turn or without a Turn | Break the same-Turn sequence. A Node without a Turn remains independent, including an unsplit Assistant. |
|
||||
| Hidden Nodes, system prompts, ordinary Context injection, and `permission` commands | Excluded from the grouping input; they neither join nor split a group. |
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
| 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 |
|
||||
| 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 |
|
||||
|
||||
最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。用户与 steering 输入、触发通知、终止错误、最大 token 提示及已完成轮次页脚保持独立。二级分组把模型重试视为分隔节点,但整轮折叠仍包含重试行。
|
||||
最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。[分组树位置](../contract/group-tree.ts)为 `turn` 或更外层的 Node 不参与整轮折叠。轮次输入位于 `input` 或 `exchange`:用户与 steering 消息、触发通知;过程开始后出现的输入使整个轮次保持展开。终止错误、最大 token 提示、已完成轮次页脚和整轮控件位于 `turn`。`process` 位置的 Node(如模型重试)分隔二级分组,但整轮折叠仍包含它。
|
||||
|
||||
加载更早的一页保留用户的组级开合选择。最终答案未变化且仍符合整轮折叠条件时,新加载的过程内容沿用该轮的开合状态;若新页揭示中途输入,则改用各组开合,不再整轮隐藏。真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。
|
||||
|
||||
@@ -198,15 +198,14 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。
|
||||
|
||||
当 `question-reply` 和 `turn-trigger` 投影自同一个消息 id 时,分组只引用问答回复。两个 Node 都保留在 Node Store 中;重复的触发节点既不显示,也不切开分组。整轮控件位于包含开场回复的分组之前。所属轮次尚未加载时也执行这一聚合。
|
||||
分组读取每个 Node 由其 Definition 设定的[分组树位置](../contract/group-tree.ts),而不是它的 kind。
|
||||
|
||||
| 输入 | 归属与切分 |
|
||||
|---|---|
|
||||
| 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 |
|
||||
| Assistant 回复 | 结束前面的组,输出独立的 `response` 引用。同一 Node 含有推理时,先将推理加入组,再结束该组。 |
|
||||
| `user`、`steering`、`turn-trigger`、`model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail` | 结束前面的组,Node 作为独立根引用保留。 |
|
||||
| `turn-process` | 控件作为独立根引用保留,不结束当前组。 |
|
||||
| 轮次内其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 |
|
||||
| 位于 `process` 或更外层位置的 Node:`model-retry`、轮次输入、`turn-error`、`turn-max-tokens`、`turn-tail` 及 `turn-process` 控件 | 结束前面的组,Node 作为独立根引用保留。 |
|
||||
| 轮次内没有位置的其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 |
|
||||
| 另一轮次或没有轮次归属的 Node | 截断同轮次序列。无轮次 Node 保持独立,Assistant 也不拆分。 |
|
||||
| 隐藏 Node、系统提示词、普通 Context 注入及 `permission` 命令 | 不进入分组输入,既不入组,也不截断组。 |
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot,
|
||||
ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
|
||||
import { isAtOrOutside } from '../contract/group-tree.ts'
|
||||
import { locationCoordinates } from './common.ts'
|
||||
import { sessionRecallLabels, skillInvocationName } from './event-projection.ts'
|
||||
import { GroupTreeSegments } from './group-tree-segments.ts'
|
||||
@@ -457,7 +457,7 @@ function turnProcessPresentations(
|
||||
if (location.kind !== 'turn' && location.kind !== 'step') continue
|
||||
const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
|
||||
const controlAnchor = current.control?.data.controlAnchorSeq
|
||||
if ((node.kind === 'user' || node.kind === 'turn-trigger' || node.kind === 'steering')
|
||||
if (isAtOrOutside(node, 'input')
|
||||
&& controlAnchor !== undefined
|
||||
&& (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor)) {
|
||||
presentations.set(location.turn.turn, {
|
||||
@@ -466,7 +466,7 @@ function turnProcessPresentations(
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue
|
||||
if (isAtOrOutside(node, 'turn')) continue
|
||||
presentations.set(location.turn.turn, {
|
||||
...current,
|
||||
earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq),
|
||||
@@ -497,7 +497,7 @@ function presentationPosition(
|
||||
const openingInputAnchor = presentation.openingInputAnchor
|
||||
if (openingInputAnchor !== undefined
|
||||
&& node.anchorSeq < openingInputAnchor
|
||||
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
|
||||
&& !isAtOrOutside(node, 'turn')) {
|
||||
return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
if (presentation.control !== undefined && node.key === presentation.control.key) {
|
||||
@@ -1148,6 +1148,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
|| previous.kind !== node.kind
|
||||
|| previous.anchorSeq !== node.anchorSeq
|
||||
|| previous.visibility !== node.visibility
|
||||
|| previous.groupTree !== node.groupTree
|
||||
|| locationIdentity(previous.location) !== locationIdentity(node.location)
|
||||
structural ||= nodeStructural
|
||||
if (!nodeStructural) contentOnly.push(node)
|
||||
|
||||
@@ -118,14 +118,14 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
// Human input claimed for Step 1 opens an exchange; Notice-woken Turns and
|
||||
// later steering stay in the current exchange, so they never fold earlier Turns.
|
||||
const location = contextLocation(context)
|
||||
const opensExchange = (context.state.kind === 'user' || context.state.kind === 'steering')
|
||||
&& location.kind === 'step' && location.step.step === 1
|
||||
const human = context.state.kind === 'user' || context.state.kind === 'steering'
|
||||
const opensExchange = human && location.kind === 'step' && location.step.step === 1
|
||||
return chatNode(
|
||||
context,
|
||||
waking ? 'turn-trigger' : context.state.kind,
|
||||
context.state.seq,
|
||||
context.state,
|
||||
{ location, groupTree: opensExchange ? 'exchange' : undefined },
|
||||
{ location, groupTree: opensExchange ? 'exchange' : (human || waking) ? 'input' : undefined },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.
|
||||
import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts'
|
||||
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
|
||||
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
|
||||
import { isAtOrOutside } from '../contract/group-tree.ts'
|
||||
import { processActivity } from './process-activity.ts'
|
||||
|
||||
const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail'])
|
||||
type ProcessInput = ConversationGroupInput<ChatConversationViewNode>
|
||||
|
||||
function turnOf(node: ChatNode): number | undefined {
|
||||
@@ -44,6 +44,7 @@ function structureChanged(previous: ChatNode | undefined, current: ChatNode): bo
|
||||
return previous === undefined || previous.kind !== current.kind
|
||||
|| turnOf(previous) !== turnOf(current)
|
||||
|| isVisibleChatNode(previous) !== isVisibleChatNode(current)
|
||||
|| previous.groupTree !== current.groupTree
|
||||
|| reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current)
|
||||
}
|
||||
|
||||
@@ -53,15 +54,6 @@ function readNode(input: ProcessInput, key: NodeKey): ChatNode {
|
||||
return node
|
||||
}
|
||||
|
||||
function questionReplyIds(input: ProcessInput, keys: readonly NodeKey[]): ReadonlySet<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const key of keys) {
|
||||
const node: ChatConversationViewNode = readNode(input, key)
|
||||
if (node.kind === 'question-reply') ids.add(node.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** One group's members and cached summary, refreshed together when its content changes. */
|
||||
class ProcessGroup {
|
||||
private nodes: readonly ChatNode[] = []
|
||||
@@ -152,23 +144,17 @@ class TurnGroups {
|
||||
}
|
||||
let previous: NodeKey | undefined
|
||||
let followed = false
|
||||
const keys = input.readTurn(this.turn)
|
||||
const replies = questionReplyIds(input, keys)
|
||||
for (const key of keys) {
|
||||
for (const key of input.readTurn(this.turn)) {
|
||||
const position = readPosition(input, key)
|
||||
if (previous !== undefined && position.previous !== previous) flush(true)
|
||||
previous = key
|
||||
followed = position.next !== undefined
|
||||
const node = readNode(input, key)
|
||||
// Both Definitions retain the same message id; only its question presentation renders.
|
||||
if (node.kind === 'turn-trigger' && replies.has(node.id)) continue
|
||||
if (INDEPENDENT.has(node.kind)) {
|
||||
if (isAtOrOutside(node, 'process')) {
|
||||
flush(true)
|
||||
emit(key, { kind: 'node', key })
|
||||
} else if (node.kind === 'turn-process') {
|
||||
// A coalesced opening reply can sort before the control that folds its group.
|
||||
emit(pending[0]?.key ?? key, { kind: 'node', key })
|
||||
} else if (node.kind === 'assistant-step') {
|
||||
// One Node, two rows: reasoning joins the step run, a reply stands outside it.
|
||||
if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' })
|
||||
if (reply(node)) {
|
||||
flush(true)
|
||||
@@ -291,14 +277,9 @@ export class ProcessState {
|
||||
}
|
||||
|
||||
private rootEntries(input: ProcessInput): RenderEntry[] {
|
||||
const unscoped = input.order.filter(key => readPosition(input, key).turn === undefined)
|
||||
const replies = questionReplyIds(input, unscoped)
|
||||
return input.order.flatMap<RenderEntry>((key) => {
|
||||
const turn = readPosition(input, key).turn
|
||||
if (turn === undefined) {
|
||||
const node = readNode(input, key)
|
||||
return node.kind === 'turn-trigger' && replies.has(node.id) ? [] : [{ kind: 'node', key }]
|
||||
}
|
||||
if (turn === undefined) return [{ kind: 'node', key }]
|
||||
const groups = this.turns.get(turn)
|
||||
if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`)
|
||||
return groups.references(key)
|
||||
|
||||
@@ -84,7 +84,7 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
|
||||
const current = attempts.at(-1)
|
||||
if (current === undefined) return null
|
||||
const data: RetryChatData = { attempts, current }
|
||||
return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data)
|
||||
return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data, { groupTree: 'process' })
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
|
||||
message: failure.message,
|
||||
...failure.code === undefined ? {} : { code: failure.code },
|
||||
}
|
||||
return chatNode(context, 'turn-error', node.seq, node)
|
||||
return chatNode(context, 'turn-error', node.seq, node, { groupTree: 'turn' })
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensSt
|
||||
turn: state.turn,
|
||||
step: lastStep(context),
|
||||
}
|
||||
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
|
||||
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node, { groupTree: 'turn' })
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type {
|
||||
ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
|
||||
import { isAtOrOutside } from '../contract/group-tree.ts'
|
||||
|
||||
function nodeTurn(node: ChatNode | undefined): number | undefined {
|
||||
const location = node?.location
|
||||
@@ -41,7 +41,7 @@ function derivePresentation(
|
||||
let openingHumanAnchor: number | undefined
|
||||
for (const key of keys) {
|
||||
const node = nodes.get(key) as ChatNode | undefined
|
||||
if ((node?.kind === 'user' || node?.kind === 'steering' || node?.kind === 'turn-trigger')
|
||||
if (node !== undefined && isAtOrOutside(node, 'input')
|
||||
&& (spec.controlAnchorSeq === location.turn.start?.seq || node.anchorSeq < spec.controlAnchorSeq)) {
|
||||
openingHumanAnchor = Math.max(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq)
|
||||
}
|
||||
@@ -53,12 +53,12 @@ function derivePresentation(
|
||||
for (const key of keys) {
|
||||
const node = nodes.get(key) as ChatNode | undefined
|
||||
if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue
|
||||
if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger')
|
||||
if (isAtOrOutside(node, 'input')
|
||||
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)) {
|
||||
hasInterleavedInput = true
|
||||
if (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq) compactAnswer = false
|
||||
}
|
||||
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
|
||||
if (isAtOrOutside(node, 'turn')
|
||||
|| 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) {
|
||||
|
||||
@@ -296,6 +296,7 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
|
||||
'turn-process',
|
||||
data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl,
|
||||
data,
|
||||
{ groupTree: 'turn' },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
|
||||
buildViewNode: (context) => {
|
||||
const turn = turnLocation(context)
|
||||
const data = turn?.data.get('turn-tail')
|
||||
return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data)
|
||||
return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data, { groupTree: 'turn' })
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-s
|
||||
import { ChatTurnProcessProjector } from '../src/client/conversation-nodes/turn-process-presentation.ts'
|
||||
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
|
||||
import {
|
||||
isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS,
|
||||
isSubagentDelegationTool, sameTurnProcessSpec,
|
||||
type TurnProcessSpec,
|
||||
} from '../src/client/contract/turn-process.ts'
|
||||
import { isAtOrOutside } from '../src/client/contract/group-tree.ts'
|
||||
import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts'
|
||||
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
@@ -245,6 +245,19 @@ class FixtureTurnDataStore implements ConversationLocationDataStore<Conversation
|
||||
}
|
||||
}
|
||||
|
||||
/** Positions the production Definitions declare for the legacy kinds this fixture projects. */
|
||||
function fixtureGroupTree(kind: string, inTurn: boolean): ChatGroupTree | undefined {
|
||||
switch (kind) {
|
||||
// A fixture user message inside a Turn stands for its opening human input.
|
||||
case 'user': return inTurn ? 'exchange' : 'input'
|
||||
case 'steering': return 'input'
|
||||
case 'turn-error':
|
||||
case 'turn-max-tokens': return 'turn'
|
||||
case 'model-retry': return 'process'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function assistantData(node: AssistantMessageNode): FinalAssistantChatData {
|
||||
return {
|
||||
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
|
||||
@@ -272,8 +285,7 @@ function settledNode(
|
||||
? { kind: 'session' as const }
|
||||
: { kind: 'turn' as const, turn },
|
||||
visibility: 'visible' as const,
|
||||
// A fixture user message inside a Turn stands for its opening human input.
|
||||
groupTree: node.kind === 'user' && turn !== undefined ? 'exchange' as const : undefined,
|
||||
groupTree: fixtureGroupTree(node.kind, turn !== undefined),
|
||||
}
|
||||
switch (node.kind) {
|
||||
case 'assistant':
|
||||
@@ -426,7 +438,7 @@ export function chatSnapshotFixture(input: {
|
||||
const turn = turns.get(turnNumber)
|
||||
const controlAnchorSeq = controlAnchor?.anchorSeq ?? turn?.start?.seq
|
||||
if (controlAnchorSeq === undefined) continue
|
||||
const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind))
|
||||
const processStart = inTurn.find(candidate => !isAtOrOutside(candidate, 'turn'))
|
||||
?? controlAnchor
|
||||
const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true
|
||||
const candidate: TurnProcessSpec = {
|
||||
@@ -463,6 +475,7 @@ export function chatSnapshotFixture(input: {
|
||||
anchorSeq: spec.controlAnchorSeq - 0.1,
|
||||
location: { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
groupTree: 'turn',
|
||||
data: spec,
|
||||
})
|
||||
}
|
||||
@@ -503,6 +516,7 @@ export function chatSnapshotFixture(input: {
|
||||
anchorSeq: endSeq,
|
||||
location: { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
groupTree: 'turn',
|
||||
data: tailData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2005,6 +2005,23 @@ describe('ChatView', () => {
|
||||
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
|
||||
})
|
||||
|
||||
it('matches echoes past an input Node whose payload carries no message source', () => {
|
||||
const base = chatSnapshotFixture({ nodes: [user(1, 'question')] })
|
||||
const snapshot = new ChatSnapshotBuilder().replace({
|
||||
nodes: [...base.nodes.values(), {
|
||||
key: 'plugin-input', id: 'plugin-input', kind: 'plugin-input', target: 'chat', anchorSeq: 2,
|
||||
location: { kind: 'session' }, visibility: 'visible', groupTree: 'input', data: null,
|
||||
}],
|
||||
timeline: base.timeline,
|
||||
})
|
||||
const h = makeHarness({}, { pendingSubmissions: [{
|
||||
requestId: 'pending' as never, placement: 'transcript', time: 1_000, text: 'pending text', attachments: [],
|
||||
}] }, snapshot)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('pending text')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-submission-echo]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
|
||||
'uses the first local opening echo until Host admission identifies the input (%s)', (hostOrder) => {
|
||||
const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(text => ({
|
||||
|
||||
@@ -659,7 +659,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('opens exchanges only for human input claimed for Step 1', () => {
|
||||
it('opens exchanges only for human input claimed for Step 1; every other input stays an input', () => {
|
||||
const human = textMessage('block-human', 'first task')
|
||||
const steer = textMessage('block-steer', 'adjust course')
|
||||
const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } }
|
||||
@@ -693,7 +693,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
at(15, 'turn/start', { turn: 2 }),
|
||||
at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
|
||||
])
|
||||
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null]])
|
||||
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input']])
|
||||
expect(bottomTurns(snapshot(value))).toEqual([1, 2])
|
||||
|
||||
value.append(at(17, 'step/start', { turn: 2, step: 1 }))
|
||||
@@ -702,7 +702,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
value.append(at(20, 'step/end', { turn: 2, step: 1 }))
|
||||
value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } }))
|
||||
value.flush()
|
||||
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null], ['turn-trigger', null]])
|
||||
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input'], ['turn-trigger', 'input']])
|
||||
expect(bottomTurns(snapshot(value))).toEqual([1, 2])
|
||||
|
||||
value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] }))
|
||||
|
||||
@@ -25,12 +25,13 @@ function members(segments: GroupTreeSegments, turns: readonly number[]): number[
|
||||
|
||||
describe('Chat group tree', () => {
|
||||
it('orders positions from the outermost exchange inward', () => {
|
||||
const at = (groupTree?: ChatGroupTree) => (['exchange', 'turn', 'process'] as const)
|
||||
const at = (groupTree?: ChatGroupTree) => (['exchange', 'input', 'turn', 'process'] as const)
|
||||
.map(position => isAtOrOutside({ groupTree }, position))
|
||||
expect(at('exchange')).toEqual([true, true, true])
|
||||
expect(at('turn')).toEqual([false, true, true])
|
||||
expect(at('process')).toEqual([false, false, true])
|
||||
expect(at(undefined)).toEqual([false, false, false])
|
||||
expect(at('exchange')).toEqual([true, true, true, true])
|
||||
expect(at('input')).toEqual([false, true, true, true])
|
||||
expect(at('turn')).toEqual([false, false, true, true])
|
||||
expect(at('process')).toEqual([false, false, false, true])
|
||||
expect(at(undefined)).toEqual([false, false, false, false])
|
||||
})
|
||||
|
||||
it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => {
|
||||
|
||||
@@ -45,23 +45,13 @@ function separator(seq: number, inTurn: boolean): ChatNode<'user'> | ChatNode<'s
|
||||
const user: ChatNode<'user'> = {
|
||||
key: `user:${seq}`, id: `user:${seq}`, kind: 'user', target: 'chat', anchorSeq: seq,
|
||||
location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible',
|
||||
// Neither location is the Step 1 that opens an exchange.
|
||||
groupTree: 'input',
|
||||
data: { kind: 'user', seq, time: seq, content: [{ type: 'text', text: 'continue' }], source: null },
|
||||
}
|
||||
return inTurn ? { ...user, kind: 'steering', data: { ...user.data, kind: 'steering', messageId: 'steer' as MessageId } } : user
|
||||
}
|
||||
|
||||
function replyNodes(inTurn: boolean): readonly [ChatConversationViewNode, ChatConversationViewNode] {
|
||||
const trigger: ChatConversationViewNode = {
|
||||
key: 'generic-reply', id: 'reply-message', kind: 'turn-trigger', target: 'chat', anchorSeq: 6,
|
||||
location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible',
|
||||
data: {
|
||||
kind: 'context', seq: 6, time: 6, content: [{ type: 'text', text: 'answer' }],
|
||||
source: { kind: 'user-question-reply' }, producer: { role: 'inject', label: null }, form: null,
|
||||
},
|
||||
}
|
||||
return [trigger, { ...trigger, key: 'question-reply', kind: 'question-reply', data: {} }]
|
||||
}
|
||||
|
||||
function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = timeline) {
|
||||
const builder = new ChatSnapshotBuilder()
|
||||
const state = new ProcessState()
|
||||
@@ -243,69 +233,51 @@ describe('Definition-owned Chat process groups', () => {
|
||||
expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' })
|
||||
})
|
||||
|
||||
it.each(['a-trigger', 'z-trigger'])('renders one question reply without splitting its group for duplicate %s', (key) => {
|
||||
const [generic, reply] = replyNodes(true)
|
||||
const trigger = { ...generic, key }
|
||||
const h = harness([tool('before', 4), trigger, reply, tool('after', 8)])
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
|
||||
const group = h.store.entries[0]!
|
||||
if (group.kind !== 'group') throw new Error('expected group')
|
||||
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))
|
||||
.toEqual(['before', reply.key, 'after'])
|
||||
expect(h.snapshot.nodes.get(trigger.key)).toBeDefined()
|
||||
expect(h.snapshot.nodes.get(reply.key)).toBeDefined()
|
||||
it.each(['process', 'turn', 'input'] as const)('ends the group at a Node declared at %s, whatever its kind', (position) => {
|
||||
const h = harness([tool('before', 4), { ...tool('standalone', 5), groupTree: position }, tool('after', 8)])
|
||||
expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'standalone', 'group'])
|
||||
})
|
||||
|
||||
it.each([0.9, 5])('keeps the Turn control before its coalesced opening reply at seq %s', (anchorSeq) => {
|
||||
it('regroups when a Node changes its position without changing its kind', () => {
|
||||
const middle = tool('middle', 5)
|
||||
const h = harness([tool('before', 4), middle, tool('after', 8)])
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
|
||||
h.builder.apply({ upserts: [{ ...middle, groupTree: 'process' }], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'middle', 'group'])
|
||||
})
|
||||
|
||||
it('keeps the Turn control as an independent root before the process it folds', () => {
|
||||
const control: ChatNode<'turn-process'> = {
|
||||
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq,
|
||||
location: { kind: 'turn', turn }, visibility: 'visible',
|
||||
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 0.9,
|
||||
location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn',
|
||||
data: {
|
||||
turn: 1, controlAnchorSeq: 1, processStartSeq: 1, answerAnchorSeq: null,
|
||||
answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0,
|
||||
},
|
||||
}
|
||||
const h = harness([control, ...replyNodes(true), tool('after', 8)])
|
||||
const h = harness([control, tool('first', 6), tool('after', 8)])
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group'])
|
||||
expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key })
|
||||
})
|
||||
|
||||
it('aggregates a newly projected reply with its existing trigger', () => {
|
||||
const [trigger, reply] = replyNodes(true)
|
||||
const h = harness([tool('before', 4), trigger, tool('after', 8)])
|
||||
h.builder.apply({ upserts: [reply], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
|
||||
const group = h.store.entries[0]!
|
||||
it('sorts a later-anchored Turn control before the process of a Turn without opening input', () => {
|
||||
// A Turn opened by a reply that declares no position: the control's own anchor follows the reply.
|
||||
const control: ChatNode<'turn-process'> = {
|
||||
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 6.9,
|
||||
location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn',
|
||||
data: {
|
||||
turn: 1, controlAnchorSeq: 6, processStartSeq: 1, answerAnchorSeq: null,
|
||||
answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0,
|
||||
},
|
||||
}
|
||||
const h = harness([tool('reply', 6), control, tool('after', 8)])
|
||||
expect(h.snapshot.order).toEqual(['control', 'reply', 'after'])
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group'])
|
||||
expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key })
|
||||
const group = h.store.entries[1]!
|
||||
if (group.kind !== 'group') throw new Error('expected group')
|
||||
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))
|
||||
.toEqual(['before', reply.key, 'after'])
|
||||
|
||||
h.builder.apply({ upserts: [{ ...reply, visibility: 'hidden' }], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group'])
|
||||
expect(h.store.entries[1]).toEqual({ kind: 'node', key: trigger.key })
|
||||
})
|
||||
|
||||
it('aggregates reply projections before their Turn has been loaded', () => {
|
||||
const [trigger, reply] = replyNodes(false)
|
||||
const h = harness([trigger, reply])
|
||||
expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }])
|
||||
expect(h.snapshot.nodes.get(trigger.key)).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps unscoped aggregation current when a reply arrives', () => {
|
||||
const [trigger, reply] = replyNodes(false)
|
||||
const h = harness([trigger])
|
||||
h.builder.apply({ upserts: [reply], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }])
|
||||
})
|
||||
|
||||
it.each([true, false])('does not merge identical text from different messages, scoped: %s', (inTurn) => {
|
||||
const [trigger, reply] = replyNodes(inTurn)
|
||||
const h = harness([trigger, { ...reply, id: 'different-message', data: trigger.data }])
|
||||
expect(h.store.entries).toContainEqual({ kind: 'node', key: trigger.key })
|
||||
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)).toEqual(['reply', 'after'])
|
||||
})
|
||||
|
||||
it('retains a group through repeated prepends and a later append', () => {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: eef4f18c3972ccc4
|
||||
zh: 6be65c6ac84ce497
|
||||
/deepseek-ai-dsh-client-ui-conversation/conversation-assembly:
|
||||
en: 6aefa09bb22fdf81
|
||||
zh: 0641c4196437cf53
|
||||
en: ef43c4f149195939
|
||||
zh: 49fc6c83fd3a05ce
|
||||
/deepseek-ai-dsh-client-ui-conversation/sent-user-text:
|
||||
en: 9912b286384c41d7
|
||||
zh: 3e994887c1948677
|
||||
|
||||
@@ -38,7 +38,7 @@ Definitions may share the same read-only Match and Location for one input and li
|
||||
|
||||
Registration accepts the existing callable `match` or a read-only event-type-to-handler table. The Registry copies table entries and precomputes ordered candidate Sets on registration changes. Function-form Definitions keep their callable API and receive every event; table handlers receive only their declared types. Changing a table requires unregistering and registering again. Legacy assembler adapters with only `entries()` and `fallbackEntry()` retain their original dispatch path.
|
||||
|
||||
All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs not claimed by an ordinary Definition for its target.
|
||||
All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs that no ordinary Definition of its target matched. A Definition can return `exclusive: true` from `match()` to claim an event on its target: other Definitions of that target do not receive the event and the target's fallback does not run ([semantics](../../../docs/subsystems/conversation.md#definition-and-typed-chat-payload)).
|
||||
|
||||
Location lookup indexes Steps by Turn and Step number, so a non-boundary append does not scan the Turn's Step array. Each input's numeric coordinates and resolved Location share one private index record; published Location values remain immutable. Boundary updates, replacement, and prepend keep the indexes aligned with the current timeline.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ kind: "package-reference"
|
||||
|
||||
注册入口接受原有可调用的 `match`,或只读的事件类型到处理函数表。Registry 在注册关系变化时复制表条目并预计算有序候选 Set。函数形式的 Definition 保持原有可调用 API,接收全部事件;表中的处理函数只接收已声明类型。修改表需要注销后重新注册。只有 `entries()` 与 `fallbackEntry()` 的旧 assembler 适配器保留原有分发路径。
|
||||
|
||||
所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理未被其目标的普通 Definition 认领的输入。
|
||||
所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理其目标上没有普通 Definition 匹配的输入。Definition 可以从 `match()` 返回 `exclusive: true`,在其目标上认领一个事件:该目标的其他 Definition 收不到这个事件,该目标的 fallback 也不运行([语义](../../../docs/subsystems/conversation.zh.md#definition-and-typed-chat-payload))。
|
||||
|
||||
Location 查询按 Turn 和 Step 编号索引 Step,因此非边界 append 不扫描 Turn 的 Step 数组。每个输入的数值坐标与解析出的 Location 共用一条私有索引记录;已发布的 Location 值保持不可变。边界更新、完整替换和向前补页使这些索引与当前时间线保持一致。
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ import type {
|
||||
export interface ConversationMatchResult {
|
||||
readonly id: string
|
||||
readonly role: 'start' | 'update'
|
||||
/**
|
||||
* Claim this event on the Definition's target: no other Definition of the
|
||||
* same target receives it, and that target's unmatched-event fallback does
|
||||
* not run. Definitions of other targets and Definitions without a target
|
||||
* still receive it. Two claims on one target, or a claim from a Definition
|
||||
* without a target, throw during dispatch. A claim returned by the fallback
|
||||
* itself has no effect, because the fallback runs only when no Definition of
|
||||
* its target matched.
|
||||
*/
|
||||
readonly exclusive?: true
|
||||
}
|
||||
|
||||
/** Merge-extensible business values published against one Turn. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
ConversationContextReader, ConversationLocationData, ConversationMatch,
|
||||
ConversationContextReader, ConversationLocationData, ConversationMatch, ConversationMatchResult,
|
||||
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
|
||||
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
|
||||
ConversationStartMatch,
|
||||
@@ -186,6 +186,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
private readonly groups = new Map<string, GroupState>()
|
||||
private readonly pendingGroupStores = new Set<ConversationGroupStore<unknown>>()
|
||||
private readonly activeTargets = new Set<string>()
|
||||
/** Matches of the event in dispatch, kept by index; dispatch is not reentrant and reuses both arrays. */
|
||||
private readonly matchedDefinitions: ConversationNodeDefinition[] = []
|
||||
private readonly matchedResults: ConversationMatchResult[] = []
|
||||
private hasMore = false
|
||||
private replacePending = true
|
||||
private timelineDirty = true
|
||||
@@ -493,37 +496,58 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
? startMatch ??= conversationMatch(input, role, location)
|
||||
: updateMatch ??= conversationMatch(input, role, location)
|
||||
}
|
||||
let firstTarget: string | undefined
|
||||
let secondTarget: string | undefined
|
||||
let otherTargets: Set<string> | undefined
|
||||
let publication: ConversationPublication = 'none'
|
||||
const definitions = this.matchedDefinitions
|
||||
const results = this.matchedResults
|
||||
definitions.length = 0
|
||||
results.length = 0
|
||||
const routes = this.eventDefinitions.forEvent?.(event.type)
|
||||
if (routes === undefined) {
|
||||
for (const definition of this.eventDefinitions.entries()) {
|
||||
const result = definition.match(event)
|
||||
if (result === null) continue
|
||||
// Inline target tracking avoids an additional closure allocation for each event.
|
||||
/* jscpd:ignore-start */
|
||||
if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) {
|
||||
if (firstTarget === undefined) firstTarget = definition.target
|
||||
else if (secondTarget === undefined) secondTarget = definition.target
|
||||
else (otherTargets ??= new Set()).add(definition.target)
|
||||
}
|
||||
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
|
||||
/* jscpd:ignore-end */
|
||||
definitions.push(definition)
|
||||
results.push(result)
|
||||
}
|
||||
} else {
|
||||
for (const { definition, match } of routes) {
|
||||
const result = match(event)
|
||||
if (result === null) continue
|
||||
if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) {
|
||||
if (firstTarget === undefined) firstTarget = definition.target
|
||||
else if (secondTarget === undefined) secondTarget = definition.target
|
||||
else (otherTargets ??= new Set()).add(definition.target)
|
||||
}
|
||||
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
|
||||
definitions.push(definition)
|
||||
results.push(result)
|
||||
}
|
||||
}
|
||||
// Claims are resolved before any acceptance, so the outcome does not depend on registration order.
|
||||
let claims: Map<string, string> | undefined
|
||||
for (let index = 0; index < results.length; index++) {
|
||||
if ((results[index] as ConversationMatchResult).exclusive !== true) continue
|
||||
const definition = definitions[index] as ConversationNodeDefinition
|
||||
const target = definition.target
|
||||
if (target === undefined) {
|
||||
throw new Error(`conversation Definition "${definition.kind}" claims ${event.type} at seq ${String(event.seq)} without a target`)
|
||||
}
|
||||
const claimant = claims?.get(target)
|
||||
if (claimant !== undefined) {
|
||||
throw new Error(`conversation Definitions "${claimant}" and "${definition.kind}" both claim ${event.type} at seq ${String(event.seq)} on target "${target}"`)
|
||||
}
|
||||
(claims ??= new Map()).set(target, definition.kind)
|
||||
}
|
||||
let firstTarget: string | undefined
|
||||
let secondTarget: string | undefined
|
||||
let otherTargets: Set<string> | undefined
|
||||
let publication: ConversationPublication = 'none'
|
||||
for (let index = 0; index < results.length; index++) {
|
||||
const definition = definitions[index] as ConversationNodeDefinition
|
||||
const result = results[index] as ConversationMatchResult
|
||||
const target = definition.target
|
||||
if (target !== undefined && result.exclusive !== true && claims?.has(target) === true) continue
|
||||
// Inline target tracking avoids an additional closure allocation for each event.
|
||||
if (target !== undefined && target !== firstTarget && target !== secondTarget) {
|
||||
if (firstTarget === undefined) firstTarget = target
|
||||
else if (secondTarget === undefined) secondTarget = target
|
||||
else (otherTargets ??= new Set()).add(target)
|
||||
}
|
||||
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
|
||||
}
|
||||
const fallback = this.eventDefinitions.fallbackEntry()
|
||||
const target = fallback?.target
|
||||
if (fallback !== undefined && target !== undefined
|
||||
|
||||
@@ -1809,6 +1809,112 @@ describe('ConversationNodeAssembler', () => {
|
||||
expect(testSnapshot(assembler)?.order).toEqual([])
|
||||
})
|
||||
|
||||
describe('exclusive matches', () => {
|
||||
const run = (seq: number, name: string) =>
|
||||
input(at(SessionSeq(seq), 'command/run', { commandId: `c${String(seq)}`, name }))
|
||||
const commandName = (event: SessionEventLike): string | undefined => (event.type as string) === 'command/run'
|
||||
? (event.data as { readonly name: string }).name
|
||||
: undefined
|
||||
|
||||
function genericDefinition(start = vi.fn(() => 'generic'), target = 'test'): ConversationNodeDefinition<string> {
|
||||
return {
|
||||
kind: `generic-${target}`,
|
||||
target,
|
||||
match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' },
|
||||
start,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => target === 'test' ? node(context, context.state) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// `null` builds a Definition without a target; an omitted argument keeps the test target.
|
||||
function claimingDefinition(kind = 'specific', target: string | null = 'test'): ConversationNodeDefinition<string> {
|
||||
return {
|
||||
kind,
|
||||
...target === null ? {} : { target },
|
||||
match: event => commandName(event) === 'claimed'
|
||||
? { id: String(event.seq), role: 'start', exclusive: true }
|
||||
: null,
|
||||
start: () => kind,
|
||||
update: context => context.state,
|
||||
...target === null ? {} : { buildViewNode: (context: ConversationNodeContext<string>) => node(context, context.state) },
|
||||
}
|
||||
}
|
||||
|
||||
function nodeData(assembler: ConversationNodeAssembler): unknown[] {
|
||||
return [...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)
|
||||
}
|
||||
|
||||
it.each(['claim first', 'claim last'] as const)(
|
||||
'withholds the claimed event from other Definitions of the target when registered %s',
|
||||
(order) => {
|
||||
const genericStart = vi.fn(() => 'generic')
|
||||
const definitions = [claimingDefinition(), genericDefinition(genericStart)]
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions(order === 'claim first' ? definitions : [...definitions].reverse()),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([run(1, 'claimed'), run(2, 'other')], false)
|
||||
assembler.flush()
|
||||
assembler.append(run(3, 'claimed'))
|
||||
assembler.flush()
|
||||
|
||||
expect(genericStart).toHaveBeenCalledOnce()
|
||||
expect(nodeData(assembler)).toEqual(['specific', 'generic', 'specific'])
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps delivering the claimed event to other targets, target-less Definitions, and no fallback', () => {
|
||||
const otherTargetStart = vi.fn(() => 'generic')
|
||||
const stateOnlyStart = vi.fn(() => null)
|
||||
const stateOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'state-only',
|
||||
match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' },
|
||||
start: stateOnlyStart,
|
||||
update: context => context.state,
|
||||
}
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions(
|
||||
[claimingDefinition(), genericDefinition(otherTargetStart, 'trajectory'), stateOnly],
|
||||
fallbackDefinition(fallbackStart),
|
||||
),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([run(1, 'claimed')], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(otherTargetStart).toHaveBeenCalledOnce()
|
||||
expect(stateOnlyStart).toHaveBeenCalledOnce()
|
||||
expect(fallbackStart).not.toHaveBeenCalled()
|
||||
expect(nodeData(assembler)).toEqual(['specific'])
|
||||
})
|
||||
|
||||
it('rejects two claims on one target', () => {
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimingDefinition('first'), claimingDefinition('second')]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow(
|
||||
'conversation Definitions "first" and "second" both claim command/run at seq 1 on target "test"',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a claim from a Definition without a target', () => {
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimingDefinition('untargeted', null)]),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow(
|
||||
'conversation Definition "untargeted" claims command/run at seq 1 without a target',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects withdrawing a previously materialized Node during an incremental update', () => {
|
||||
const definition: ConversationNodeDefinition<boolean> = {
|
||||
kind: 'toggle',
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: c34dad62fee63306
|
||||
zh: 167d9da7928a9488
|
||||
/deepseek-ai-dsh-client-ui-jobs/understand-the-implementation:
|
||||
en: 4f18681e6209c2d7
|
||||
zh: 4472b22583ce8d39
|
||||
en: 4b520a2726f1a600
|
||||
zh: e0324fd12713be6e
|
||||
/deepseek-ai-dsh-client-ui-jobs/further-exploration:
|
||||
en: 01eaaecb5850f391
|
||||
zh: 2eabd14cde191ab8
|
||||
|
||||
@@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover portals to the document body, so the clipping header column and the right sidebar cannot cut it off, and is placed from its anchor and clamped inside the viewport. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
|
||||
One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. The shared anchoring hook tracks scrolling, resizing, and panel-size changes. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
|
||||
@@ -45,7 +45,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现内幕——点击展开</summary>
|
||||
|
||||
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层挂载到 document body,因此不会被裁剪的头部列或右侧边栏截断,并按锚点定位、限制在视口内。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
|
||||
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。共享锚点 hook 跟踪滚动、窗口尺寸及面板尺寸变化。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
|
||||
@@ -43,24 +43,27 @@
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 500px;
|
||||
max-width: min(560px, calc(100vw - 32px));
|
||||
max-height: min(480px, calc(100vh - 140px));
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
border: 0;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-specific-menu);
|
||||
backdrop-filter: var(--dsw-menu-backdrop-filter);
|
||||
overflow: hidden;
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
}
|
||||
|
||||
.menuBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Section labels over the live rows and the settled tail. */
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createPortal } from 'react-dom'
|
||||
import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useAnchoredPosition, useDismissOnOutsidePointer,
|
||||
IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock,
|
||||
useAnchoredPosition, useDismissOnOutsidePointer,
|
||||
type StateDotState, type TerminalBlockLabels,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -349,7 +350,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
|
||||
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude<KillState, 'idle'> } | undefined>(undefined)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLUListElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rows = useMemo(() => ordered(jobs), [jobs])
|
||||
const liveRows = useMemo(() => rows.filter(isLive), [rows])
|
||||
@@ -537,36 +538,33 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob,
|
||||
</button>
|
||||
{open
|
||||
? createPortal(
|
||||
<ul
|
||||
ref={menuRef}
|
||||
className={css.menu}
|
||||
style={menuPosition ?? MEASURE_STYLE}
|
||||
aria-label={t('list.aria')}
|
||||
>
|
||||
{liveRows.length > 0
|
||||
? <li className={css.sectionHeader} aria-hidden="true">{t('section.live')}</li>
|
||||
: null}
|
||||
{liveRows.map(item)}
|
||||
{settledRows.length > 0
|
||||
? (
|
||||
<li className={css.sectionHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sectionToggle}
|
||||
aria-expanded={settledExpanded}
|
||||
onClick={() => { setSettledOpen(!settledExpanded) }}
|
||||
>
|
||||
<IconChevronDownOutlineRegular size={12} className={settledExpanded ? `${css.sectionChevron} ${css.sectionChevronOpen}` : css.sectionChevron} />
|
||||
{t('section.settledCount', { count: settledRows.length })}
|
||||
</button>
|
||||
<button type="button" className={css.sectionClear} onClick={clearSettled}>
|
||||
{t('section.clear')}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
{settledExpanded ? settledRows.map(item) : null}
|
||||
</ul>,
|
||||
<MenuSurface ref={menuRef} className={css.menu} style={menuPosition ?? MEASURE_STYLE}>
|
||||
<ul className={css.menuBody} aria-label={t('list.aria')}>
|
||||
{liveRows.length > 0
|
||||
? <li className={css.sectionHeader} aria-hidden="true">{t('section.live')}</li>
|
||||
: null}
|
||||
{liveRows.map(item)}
|
||||
{settledRows.length > 0
|
||||
? (
|
||||
<li className={css.sectionHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sectionToggle}
|
||||
aria-expanded={settledExpanded}
|
||||
onClick={() => { setSettledOpen(!settledExpanded) }}
|
||||
>
|
||||
<IconChevronDownOutlineRegular size={12} className={settledExpanded ? `${css.sectionChevron} ${css.sectionChevronOpen}` : css.sectionChevron} />
|
||||
{t('section.settledCount', { count: settledRows.length })}
|
||||
</button>
|
||||
<button type="button" className={css.sectionClear} onClick={clearSettled}>
|
||||
{t('section.clear')}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
{settledExpanded ? settledRows.map(item) : null}
|
||||
</ul>
|
||||
</MenuSurface>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { zh } from '../src/client/locales.ts'
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -282,19 +283,86 @@ describe('JobListAction observation', () => {
|
||||
expect(writeText).toHaveBeenCalledWith('pnpm run build')
|
||||
})
|
||||
|
||||
it('portals the open list below its trigger and keeps it open on pointer presses inside', () => {
|
||||
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344, bottom: 40 } as DOMRect)
|
||||
it('anchors the portaled list in viewport coordinates and follows resize and ancestor scroll', () => {
|
||||
vi.stubGlobal('innerWidth', 700)
|
||||
vi.stubGlobal('innerHeight', 600)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120)
|
||||
const { container } = render(<JobListAction {...props([outputJob()])} />)
|
||||
openList()
|
||||
const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' })
|
||||
const anchor = vi.spyOn(trigger.parentElement!, 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(344, 40, 180, 28))
|
||||
fireEvent.click(trigger)
|
||||
const list = screen.getByRole('list', { name: zh['list.aria'] })
|
||||
const menu = list.closest<HTMLElement>('[data-menu-material]')
|
||||
expect(menu?.parentElement).toBe(document.body)
|
||||
expect(container.contains(list)).toBe(false)
|
||||
expect(menu?.style.left).toBe('248px')
|
||||
expect(menu?.style.top).toBe('73px')
|
||||
|
||||
vi.stubGlobal('innerWidth', 900)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(menu?.style.left).toBe('344px')
|
||||
anchor.mockReturnValue(new DOMRect(80, 110, 180, 28))
|
||||
fireEvent.scroll(container)
|
||||
expect(menu?.style.left).toBe('80px')
|
||||
expect(menu?.style.top).toBe('143px')
|
||||
})
|
||||
|
||||
it('keeps a near-edge anchored list inside both viewport margins', () => {
|
||||
vi.stubGlobal('innerWidth', 700)
|
||||
vi.stubGlobal('innerHeight', 600)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120)
|
||||
render(<JobListAction {...props([outputJob()])} />)
|
||||
const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' })
|
||||
vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28))
|
||||
fireEvent.click(trigger)
|
||||
const menu = screen.getByRole('list', { name: zh['list.aria'] })
|
||||
// Outside the header's clipping column, so the right sidebar cannot cover it.
|
||||
expect(container.contains(menu)).toBe(false)
|
||||
expect(menu.style.left).toBe('344px')
|
||||
expect(menu.style.top).toBe('45px')
|
||||
fireEvent.pointerDown(menu)
|
||||
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
|
||||
.closest<HTMLElement>('[data-menu-material]')
|
||||
expect(menu?.style.left).toBe('12px')
|
||||
expect(menu?.style.top).toBe('468px')
|
||||
})
|
||||
|
||||
it('keeps portaled controls interactive and releases observation on dismissal and unmount', () => {
|
||||
const stop = vi.fn()
|
||||
const observe = vi.fn(() => stop)
|
||||
const { container, unmount } = render(<JobListAction {...props([outputJob()], observe)} />)
|
||||
const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
const list = screen.getByRole('list', { name: zh['list.aria'] })
|
||||
expect(container.contains(list)).toBe(false)
|
||||
const row = screen.getByRole('button', {
|
||||
name: zh['row.expandAria'].replace('{label}', 'pnpm run build'),
|
||||
})
|
||||
fireEvent.pointerDown(row)
|
||||
fireEvent.click(row)
|
||||
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list)
|
||||
expect(observe).toHaveBeenCalledTimes(1)
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
const kill = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build'))
|
||||
fireEvent.pointerDown(kill)
|
||||
fireEvent.click(kill)
|
||||
expect(kill.getAttribute('data-kill-state')).toBe('armed')
|
||||
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list)
|
||||
|
||||
row.focus()
|
||||
fireEvent.keyDown(row, { key: 'Escape' })
|
||||
expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(trigger)
|
||||
expect(observe).toHaveBeenCalledTimes(2)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull()
|
||||
expect(stop).toHaveBeenCalledTimes(2)
|
||||
fireEvent.click(trigger)
|
||||
expect(observe).toHaveBeenCalledTimes(3)
|
||||
const reopened = screen.getByRole('list', { name: zh['list.aria'] })
|
||||
unmount()
|
||||
expect(document.body.contains(reopened)).toBe(false)
|
||||
expect(stop).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('ignores other keys while open and closes once the roster empties', () => {
|
||||
@@ -342,7 +410,7 @@ describe('JobListAction observation', () => {
|
||||
render(<JobListAction {...props([job()], undefined, { 'bash-1': view })} />)
|
||||
openList()
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
|
||||
expect(document.body.querySelector('[data-running]')).not.toBeNull()
|
||||
expect(screen.getByRole('list', { name: zh['list.aria'] }).querySelector('[data-running]')).not.toBeNull()
|
||||
expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: 87e0c952a5bfcc41
|
||||
zh: 8824e1b588fae213
|
||||
en: 005ae2cf873c0a2e
|
||||
zh: bc7ca9472105a6a0
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: fcb2e69c8b028c0a
|
||||
zh: d505a075679974eb
|
||||
|
||||
@@ -29,6 +29,8 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
|
||||
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching a bundle on or off does not change its group or position. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text.
|
||||
|
||||
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `<subpath>/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
|
||||
|
||||
On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. Updates preserve selection and report whether a restart is required. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable.
|
||||
|
||||
@@ -29,6 +29,8 @@ kind: "package-reference"
|
||||
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。启停组合包不会改变它的分组或位置。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
|
||||
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。
|
||||
|
||||
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
|
||||
|
||||
官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新保留选择状态,并报告是否需要重启。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
.pageHead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
@@ -39,6 +40,11 @@
|
||||
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
|
||||
}
|
||||
|
||||
.pageHeading {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
@@ -66,10 +72,11 @@
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addMenu {
|
||||
|
||||
@@ -1484,7 +1484,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
{showsMainList
|
||||
? (
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
<div>
|
||||
<div className={css.pageHeading}>
|
||||
<h1 className={css.pageTitle}>{t('title')}</h1>
|
||||
<div className={css.pageIntro}>
|
||||
<span>{t('intro')}</span>
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 2afd547980f6f009
|
||||
zh: bc0ab804bdf2d296
|
||||
en: 6f5457b84fc9db09
|
||||
zh: 85993f1c44bfee2c
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: f0e54915b896af5a
|
||||
zh: 2047520fd321be90
|
||||
en: 28ac8f0b1edbcf81
|
||||
zh: 5c4c324bf84a282f
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: 12fe9d9b1410edef
|
||||
zh: f2dfdc01170d7cce
|
||||
|
||||
@@ -59,7 +59,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. |
|
||||
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
|
||||
| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
|
||||
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
|
||||
| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. |
|
||||
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
|
||||
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
|
||||
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. |
|
||||
@@ -87,12 +87,14 @@ Writing your own component in your own package is fine when the need is genuinel
|
||||
|
||||
### Controls and icons
|
||||
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. A `Tooltip` nested in the anchor hides that preview while its own hover or focus label is visible; releasing, disabling, or unmounting the nested tooltip restores a still-open preview. `Toast` uses the oLine truncated
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hoveLine truncated
|
||||
|
||||
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click.
|
||||
|
||||
<a id="rendering-agent-output"></a>
|
||||
|
||||
`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent.
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user