Merge remote-tracking branch 'origin/master' into feat/page-font-size-shortcuts

# Conflicts:
#	apps/web/tests/expected/plugin-manager/header-responsive.expected.md
#	apps/web/tests/plugin-manager.e2e.ts
This commit is contained in:
fz committed 2026-10-13 10:13:38 +08:00
commit 3ea4f4baa3
60 files changed
+3326 -378

No files matched your search

@@ -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.
@@ -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 和进程不共享对象身份。
@@ -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 自动投影。
@@ -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。
@@ -1,18 +1,19 @@
# Plugin header responsive layout
en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
en 768px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
en 520px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 440px: actions below 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 the heading, single-line; contained; information follows subtitle; focus retained
zh 520px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 440px: actions below 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,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"
+84 -48
View File
@@ -103,9 +103,8 @@ describe('web e2e: plugin manager', () => {
// view-installation button, which moves the wrap threshold.
const headerScaffold = await launchWebScaffold()
try {
// The widths where the header must have wrapped or not are pinned; the
// remaining widths assert only that the action group is placed on one side
// of the heading, and the golden records which.
// 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({
@@ -125,55 +124,92 @@ describe('web e2e: plugin manager', () => {
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
await expect.poll(() => add.isEnabled()).toBe(true)
await add.focus()
const frame = probe.locator('[data-shell-bottom]').locator('..')
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 })
// The responsive sidebar changes the header's available width after ResizeObserver delivery.
await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe(width < 1024 ? 'true' : null)
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 subtitle = heading.querySelector('h1 + div > span')!
const range = document.createRange()
range.selectNodeContents(subtitle)
const textRects = Array.from(range.getClientRects())
const lastText = textRects.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,
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),
}
})
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]
await expect.poll(measure).toMatchObject({
singleLine: true, contained: true, infoFollowsText: true, focused: true,
})
const placed = (await measure()).placement
if (pinned === undefined) expect(['beside', 'below']).toContain(placed)
else expect(placed).toBe(pinned)
facts.push(`${language} ${width}px: actions ${placed} the heading, single-line; contained; information follows subtitle; focus retained`)
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()
@@ -187,7 +223,7 @@ describe('web e2e: plugin manager', () => {
}
await compareOrRefreshGolden(
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`,
`# 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 {
@@ -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([])
})
+4 -4
View File
@@ -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
+16 -6
View File
@@ -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 {
+16 -6
View File
@@ -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,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)
})
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-workspace/use-this-package:
en: b164014ee565be16
zh: 6a741647932b93e6
en: d042b4b1dd7e7f56
zh: c12fa070ef5a02a4
/deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options:
en: 8673afc1d72ad077
zh: 9fbb83a4029c5d10
+1 -1
View File
@@ -27,7 +27,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace
<a id="use-this-package"></a>
## Use this package
Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five idle, non-blank Sessions by default. Running Sessions, including parents with running children, remain visible in their ordered positions without using that quota; the selected blank **New Session** is also an extra row until its first prompt. Each **Show more** click reveals up to five more idle Sessions; after the final batch, **Show less** restores the initial rows while keeping running Sessions visible. Closing and reopening the Workspace also restores this folded projection.
Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark is a row count that never falls on its own, so the rows stay visible after a run or notification ends; a reorder that moves a Session past the watermark hides it. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; any unmount of the grouped tree resets them.
History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying.
+1 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。
用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线是一个行数,不会自行回落,因此运行或通知结束后这些行仍保持可见;重排若把某个 Session 移到水位线之后,该 Session 会被隐藏。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树的任何卸载都会重置水位线。
未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。
@@ -51,24 +51,60 @@ const EXPAND_SLIDE_MS = 300
const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Idle Session rows visible per Workspace before the local overflow control. */
const COLLAPSED_SESSION_LIMIT = 5
/** Non-blank Session rows visible per Workspace while no Session raises its watermark. */
const SESSION_WATERMARK_BASE = 5
/** Highest watermark that attention-bearing Sessions raise automatically; Show more and search reveals may exceed it. */
const SESSION_WATERMARK_AUTO_MAX = 50
/** Keep provisional and running rows outside the idle-session quota, including parents with running children. */
function collapsedSessionRows(sessions: readonly SessionNode[], limit = COLLAPSED_SESSION_LIMIT): {
/** Show the blank row plus the first `watermark` non-blank rows, in list order. */
function watermarkRows(sessions: readonly SessionNode[], watermark: number): {
rows: readonly SessionNode[]
hiddenCount: number
} {
let idleCount = 0
let count = 0
const rows = sessions.filter((session) => {
if (session.blank || session.running || session.runningSubagentCount > 0) return true
if (idleCount >= limit) return false
idleCount += 1
return true
if (session.blank) return true
count += 1
return count <= watermark
})
return { rows, hiddenCount: sessions.length - rows.length }
}
/**
* Smallest watermark that covers every non-blank row within the automatic maximum that is running, has a running
* child, awaits the user, carries an unread completion, or is current, and the reveal target at any position.
*/
function requiredWatermark(
sessions: readonly SessionNode[],
current: SessionId | undefined,
revealSessionId: SessionId | undefined,
): number {
let index = 0
let required = 0
for (const session of sessions) {
if (session.blank) continue
index += 1
const attention = index <= SESSION_WATERMARK_AUTO_MAX && (
session.running || session.runningSubagentCount > 0 || session.pendingInteraction !== undefined
|| session.completed || session.id === current
)
if (attention || session.id === revealSessionId) required = index
}
return required
}
/** Raise each listed group's watermark to its requirement; returns `marks` itself when nothing rises. */
function raiseWatermarks(
marks: Readonly<Record<string, number>>,
required: ReadonlyMap<string, number>,
): Readonly<Record<string, number>> {
let raised = marks
for (const [key, watermark] of required) {
if (watermark > (raised[key] ?? SESSION_WATERMARK_BASE)) raised = { ...raised, [key]: watermark }
}
return raised
}
/** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string {
const withoutNul = value.replaceAll('\0', '')
@@ -277,7 +313,7 @@ function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick<SessionTreePro
)
}
/** The scrolling session tree; unmounting drops the sessions subscription and local row limits. */
/** The scrolling session tree; unmounting drops the sessions subscription and the session watermarks. */
function SessionTree({
list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds,
rowState, onLeaveArchivedOnly,
@@ -297,7 +333,10 @@ function SessionTree({
const revealGroup = revealSessionId === undefined || !workspaceReady
? undefined
: owningGroupKey(workspaces, revealSessionId)
const [sessionLimits, setSessionLimits] = useState<Readonly<Record<string, number>>>({})
// Per-group raised watermarks; an absent key means the base watermark.
const [storedWatermarks, setStoredWatermarks] = useState<Readonly<Record<string, number>>>({})
// Advances on user-driven watermark changes so AnimatedRows settles them; automatic raises animate.
const [watermarkMotionKey, setWatermarkMotionKey] = useState(0)
// Transient drag marker state; the selected mode owns the resulting order.
const [drag, setDrag] = useState<DragState | null>(null)
const sessionDropCommitted = useRef(false)
@@ -347,13 +386,34 @@ function SessionTree({
}
}
}, [groupExpansion, parents, revealGroup, setGroupExpanded])
useEffect(() => {
if (revealSessionId === undefined || revealGroup === undefined) return
const group = groups.find(candidate => candidate.key === revealGroup)
if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return
if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return
setSessionLimits(limits => limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity })
}, [groups, revealGroup, revealSessionId])
const baselines = new Map(groups.map(group => [group.key, Math.max(SESSION_WATERMARK_BASE, requiredWatermark(
group.sessions,
current,
group.key === revealGroup ? revealSessionId : undefined,
))]))
const foldedKeys = new Set(groups.filter(group => !group.expanded).map(group => group.key))
const underFoldedAncestor = (key: string): boolean => {
for (let owner = parents.get(key); owner !== undefined; owner = parents.get(owner)) {
if (foldedKeys.has(owner)) return true
}
return false
}
// A group hidden by a folded ancestor keeps its own expansion and rows but must not raise its watermark.
const requiredWatermarks = new Map([...baselines].filter(([key]) => !underFoldedAncestor(key)))
const sessionWatermarks = raiseWatermarks(storedWatermarks, requiredWatermarks)
// Raising during render keeps the watermark monotonic without committing a lower frame first.
if (sessionWatermarks !== storedWatermarks) setStoredWatermarks(marks => raiseWatermarks(marks, requiredWatermarks))
const dropWatermarks = (drop: (key: string) => boolean): void => {
if (!Object.keys(storedWatermarks).some(drop)) return
setStoredWatermarks(marks => Object.fromEntries(Object.entries(marks).filter(([key]) => !drop(key))))
setWatermarkMotionKey(key => key + 1)
}
const withinGroup = (key: string, root: string): boolean => {
for (let owner: string | undefined = key; owner !== undefined; owner = parents.get(owner)) {
if (owner === root) return true
}
return false
}
const now = Date.now()
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
if (sessionDropCommitted.current) return
@@ -366,7 +426,7 @@ function SessionTree({
? ungroupedSessionIds
: workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds
if (accountSessionIds === undefined) return
const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows
const renderedSessions = watermarkRows(group.sessions, sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE).rows
const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over)
if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder)
}
@@ -416,8 +476,9 @@ function SessionTree({
const workspaceId = group.workspaceId
const children = childrenByParent.get(group.key) ?? []
const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
const collapsed = collapsedSessionRows(group.sessions)
const visible = collapsedSessionRows(group.sessions, sessionLimits[group.key])
const watermark = sessionWatermarks[group.key] ?? SESSION_WATERMARK_BASE
const collapsed = watermarkRows(group.sessions, baselines.get(group.key) ?? SESSION_WATERMARK_BASE)
const visible = watermarkRows(group.sessions, watermark)
const sessionsExpanded = visible.hiddenCount === 0
rowKeys.push(`workspace:${group.key}`)
const childRows = group.expanded ? children.map(child => renderGroup(child, depth + 1)) : []
@@ -500,9 +561,7 @@ function SessionTree({
home={home}
t={t}
onToggle={() => {
if (group.expanded) {
setSessionLimits(limits => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT }))
}
if (group.expanded) dropWatermarks(key => withinGroup(key, group.key))
setGroupExpanded(group.key, !group.expanded)
}}
onCreate={() => {
@@ -585,14 +644,15 @@ function SessionTree({
data-row-key={`overflow:${group.key}`}
aria-expanded={sessionsExpanded}
onClick={() => {
setSessionLimits(limits => ({
...limits,
[group.key]: sessionsExpanded
? COLLAPSED_SESSION_LIMIT
: visible.hiddenCount <= COLLAPSED_SESSION_LIMIT
? Infinity
: (limits[group.key] ?? COLLAPSED_SESSION_LIMIT) + COLLAPSED_SESSION_LIMIT,
if (sessionsExpanded) {
dropWatermarks(key => key === group.key)
return
}
setStoredWatermarks(marks => ({
...marks,
[group.key]: visible.hiddenCount <= SESSION_WATERMARK_BASE ? Infinity : watermark + SESSION_WATERMARK_BASE,
}))
setWatermarkMotionKey(key => key + 1)
}}
>
{sessionsExpanded
@@ -613,7 +673,7 @@ function SessionTree({
label={t('section.sessions')}
rowKeys={rowKeys}
ready={list.phase === 'ready' && workspaceReady && !nativeDragActive}
resetKey={JSON.stringify([animationResetKey, sessionLimits])}
resetKey={JSON.stringify([animationResetKey, watermarkMotionKey])}
>
{groups.length === 0 && (
<EmptySessions rowState={rowState} onLeaveArchivedOnly={onLeaveArchivedOnly} t={t} />
@@ -7,7 +7,9 @@ import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-sess
import type {
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type {
SessionPendingInteraction, SessionStatus, SessionStatusSnapshot,
} from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -770,63 +772,103 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
})
it('keeps running sessions outside every batch quota and preserves their order after collapse', () => {
const idle = Array.from({ length: 12 }, (_, index) => summary(`idle-${index + 1}`, 12 - index))
const first = summary('running-first', 14, { running: true })
const last = summary('running-last', 0, { running: true })
const blank = summary('blank', 15, { blank: true })
const items = [blank, first, ...idle, last]
mount({
useSessions: hook(sessionState(items, { main: blank.id })),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
const expectRows = (count: number) => {
expect(screen.getAllByRole('treeitem').map(row =>
within(row).getByText(/^(alpha|新会话|running-(first|last)|idle-\d+)$/u).textContent)).toEqual([
'alpha', '新会话', 'running-first', ...idle.slice(0, count).map(item => item.displayTitle), 'running-last',
])
}
expectRows(5)
fireEvent.click(screen.getByRole('button', { name: '展开其余 7 个会话' }))
expectRows(10)
fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' }))
expectRows(12)
fireEvent.click(screen.getByRole('button', { name: '收起' }))
expectRows(5)
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha'))
expectRows(5)
})
it('reveals a hidden session when live running status starts and folds it when the run ends', () => {
it('raises the watermark to the last attention row and keeps it after the attention ends', () => {
const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 12 - index))
const b = mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
const status = (id: string, value: Partial<SessionStatus>) =>
hook<SessionStatusSnapshot>(new Map([[sid(id), {
running: false, pendingInteraction: undefined, completionUnread: false, ...value,
}]]))
const expectVisible = (count: number) => {
expect(screen.getAllByRole('treeitem').map(row => within(row).getByText(/^(alpha|session-\d+)$/u).textContent))
.toEqual(['alpha', ...items.slice(0, count).map(item => item.displayTitle)])
}
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('session-12')).toBeNull()
rerender(b, {
useSessionStatus: hook<SessionStatusSnapshot>(new Map([[sid('session-12'), {
running: true, pendingInteraction: undefined, completionUnread: false,
}]])),
})
expect(screen.getByText('session-12')).toBeTruthy()
expect(screen.queryByText('session-6')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 6 个会话' })).toBeTruthy()
rerender(b, {
useSessionStatus: hook<SessionStatusSnapshot>(new Map([[sid('session-12'), {
running: false, pendingInteraction: undefined, completionUnread: true,
}]])),
})
expect(screen.queryByText('session-12')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy()
expectVisible(5)
rerender(b, { useSessionStatus: status('session-9', { running: true }) })
expectVisible(9)
expect(screen.getByRole('button', { name: '展开其余 3 个会话' })).toBeTruthy()
rerender(b, { useSessionStatus: status('session-9', {}) })
expectVisible(9)
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha'))
expectVisible(5)
rerender(b, { useSessionStatus: status('session-7', {
pendingInteraction: { key: 'question:1', kind: 'plan-review', sessionId: sid('session-7') } as SessionPendingInteraction,
}) })
expectVisible(7)
rerender(b, { useSessionStatus: status('session-8', { completionUnread: true }) })
expectVisible(8)
fireEvent.click(screen.getByRole('button', { name: '展开其余 4 个会话' }))
expectVisible(12)
fireEvent.click(screen.getByRole('button', { name: '收起' }))
expectVisible(8)
})
it('keeps a parent with a running child visible outside the idle-session quota', () => {
const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index))
it('keeps the raised row count, not the Session, when a new Session pushes it down', () => {
const items = Array.from({ length: 12 }, (_, index) => summary(`session-${index + 1}`, 20 - index))
const b = mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
fireEvent.click(screen.getByText('alpha'))
const running = (value: boolean) => hook<SessionStatusSnapshot>(new Map([[sid('session-9'), {
running: value, pendingInteraction: undefined, completionUnread: false,
}]]))
rerender(b, { useSessionStatus: running(true) })
rerender(b, { useSessionStatus: running(false) })
expect(screen.getByText('session-9')).toBeTruthy()
const newest = summary('newest', 30)
rerender(b, {
useSessions: hook(sessionState([newest, ...items])),
useWorkspaces: hook(workspaceState([workspace('alpha', [newest.id, ...items.map(item => item.id)])])),
})
expect(screen.getByText('session-8')).toBeTruthy()
expect(screen.queryByText('session-9')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 4 个会话' })).toBeTruthy()
})
it('stops automatic raises at 50 rows while Show more continues past them', () => {
const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index))
const b = mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
fireEvent.click(screen.getByText('alpha'))
const running = (...ids: string[]) => hook<SessionStatusSnapshot>(new Map(ids.map(id => [sid(id), {
running: true, pendingInteraction: undefined, completionUnread: false,
}])))
rerender(b, {
useSessions: hook(sessionState(items, { main: sid('session-53') })),
useSessionStatus: hook<SessionStatusSnapshot>(new Map([
[sid('session-55'), { running: true, pendingInteraction: undefined, completionUnread: false }],
[sid('session-56'), { running: false, pendingInteraction: undefined, completionUnread: true }],
[sid('session-57'), {
running: false, completionUnread: false,
pendingInteraction: { key: 'question:1', kind: 'question', sessionId: sid('session-57') } as SessionPendingInteraction,
}],
])),
})
expect(screen.queryByText('session-6')).toBeNull()
for (const id of ['session-53', 'session-55', 'session-56', 'session-57']) expect(screen.queryByText(id)).toBeNull()
rerender(b, { useSessionStatus: running('session-50', 'session-55') })
expect(screen.getByText('session-50')).toBeTruthy()
expect(screen.queryByText('session-51')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '展开其余 10 个会话' }))
expect(screen.getByText('session-55')).toBeTruthy()
expect(screen.queryByText('session-56')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '展开其余 5 个会话' }))
expect(screen.getByText('session-60')).toBeTruthy()
})
it('covers the current session and a parent with a running child', () => {
const items = Array.from({ length: 9 }, (_, index) => summary(`session-${index + 1}`, 9 - index))
const child = summary('child', 0, { origin: 'subagent', running: true })
mount({
const b = mount({
useSessions: hook(sessionState([...items, child], {
projectionsBySession: {
[sid('session-7')]: {
@@ -839,8 +881,11 @@ describe('WorkspaceBrowser', () => {
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.getByText('session-7')).toBeTruthy()
expect(screen.queryByText('session-6')).toBeNull()
expect(screen.queryByText('session-8')).toBeNull()
expect(screen.queryByText('child')).toBeNull()
rerender(b, { useSessions: hook(sessionState(items, { main: sid('session-8') })) })
expect(screen.getByText('session-8')).toBeTruthy()
expect(screen.queryByText('session-9')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy()
})
@@ -1548,7 +1593,7 @@ describe('WorkspaceBrowser', () => {
expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale')
})
it('reveals every session when a search result is beyond the initial five-row quota', () => {
it('raises the watermark to a search result beyond the initial five rows', () => {
const items = Array.from({ length: 17 }, (_, index) => summary(`session-${index + 1}`, 17 - index))
const b = mount({
useSessions: hook(sessionState(items)),
@@ -1560,16 +1605,28 @@ describe('WorkspaceBrowser', () => {
expect(b.props.open).toHaveBeenCalledWith(sid('session-11'))
expect(input.value).toBe('')
for (const item of items) expect(screen.getByText(item.displayTitle)).toBeTruthy()
expect(screen.queryByRole('button', { name: /展开其余/ })).toBeNull()
expect(screen.getByText('session-11')).toBeTruthy()
expect(screen.queryByText('session-12')).toBeNull()
expect(scrollIntoView.mock.instances.at(-1)).toBe(screen.getByText('session-11').closest('[role="treeitem"]'))
fireEvent.click(screen.getByRole('button', { name: '收起' }))
fireEvent.click(screen.getByRole('button', { name: '展开其余 6 个会话' }))
expect(screen.getByText('session-16')).toBeTruthy()
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('session-6')).toBeNull()
expect(screen.queryByText('session-11')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '展开其余 12 个会话' }))
expect(screen.getByText('session-10')).toBeTruthy()
expect(screen.queryByText('session-11')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 7 个会话' })).toBeTruthy()
expect(screen.getByRole('button', { name: '展开其余 12 个会话' })).toBeTruthy()
})
it('reveals a search result beyond the automatic 50-row limit', () => {
const items = Array.from({ length: 60 }, (_, index) => summary(`session-${index + 1}`, 60 - index))
mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
})
fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'session-58' } })
fireEvent.click(screen.getByRole('treeitem'))
expect(screen.getByText('session-58')).toBeTruthy()
expect(screen.queryByText('session-59')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
})
it('keeps the bounded group projection when the revealed result is already within it', () => {
@@ -2418,6 +2475,55 @@ describe('Workspace tree grouping', () => {
expect(within(section('Projects')).getByText('Child')).toBeTruthy()
})
it('folding a parent Workspace resets descendant watermarks', () => {
const rootItems = Array.from({ length: 7 }, (_, index) => summary(`root-${index + 1}`, 30 - index))
const childItems = Array.from({ length: 7 }, (_, index) => summary(`child-${index + 1}`, 20 - index))
mount({
useSessions: hook(sessionState([...rootItems, ...childItems])),
useWorkspaces: hook(workspaceState([
{ ...root, sessionIds: rootItems.map(item => item.id) },
{ ...child, sessionIds: childItems.map(item => item.id) },
])),
})
fireEvent.click(screen.getByText('Child'))
fireEvent.click(within(section('Child')).getByRole('button', { name: '展开其余 2 个会话' }))
fireEvent.click(within(section('Projects')).getAllByRole('button', { name: '展开其余 2 个会话' })[0]!)
expect(screen.getByText('child-7')).toBeTruthy()
expect(screen.getByText('root-7')).toBeTruthy()
fireEvent.click(screen.getByText('Child'))
fireEvent.click(screen.getByText('Child'))
expect(screen.queryByText('child-6')).toBeNull()
expect(screen.getByText('root-7')).toBeTruthy()
fireEvent.click(within(section('Child')).getByRole('button', { name: '展开其余 2 个会话' }))
fireEvent.click(screen.getByText('Projects'))
fireEvent.click(screen.getByText('Projects'))
expect(screen.queryByText('root-6')).toBeNull()
expect(screen.queryByText('child-6')).toBeNull()
})
it('does not raise a nested watermark while a folded ancestor hides it', () => {
const rootItems = [summary('root-1', 30)]
const childItems = Array.from({ length: 8 }, (_, index) => summary(`child-${index + 1}`, 20 - index))
const b = mount({
useSessions: hook(sessionState([...rootItems, ...childItems])),
useWorkspaces: hook(workspaceState([
{ ...root, sessionIds: rootItems.map(item => item.id) },
{ ...child, sessionIds: childItems.map(item => item.id) },
])),
})
fireEvent.click(screen.getByText('Child'))
fireEvent.click(screen.getByText('Projects'))
const status = (running: boolean) => hook<SessionStatusSnapshot>(new Map([[sid('child-8'), {
running, pendingInteraction: undefined, completionUnread: false,
}]]))
rerender(b, { useSessionStatus: status(true) })
rerender(b, { useSessionStatus: status(false) })
fireEvent.click(screen.getByText('Projects'))
expect(screen.getByText('child-5')).toBeTruthy()
expect(screen.queryByText('child-6')).toBeNull()
expect(screen.queryByText('child-8')).toBeNull()
})
it('nests newly registered ancestors and children while retaining each Workspace session', () => {
const b = mount({
useSessions: hook(sessionState([summary('root-session', 1), summary('team-session', 2), summary('child-session', 3)])),
@@ -5626,11 +5626,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'InvocationDescriptor',
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly uplink?: {\n readonly codec: TypertCodec;\n };\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly description?: string;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
},
{
name: 'InvocationParameterDescriptor',
declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypertCodec;\n readonly acceptsUndefined?: true;\n}',
declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly description?: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypertCodec;\n readonly acceptsUndefined?: true;\n}',
},
{
name: 'InvocationSourceLocation',
@@ -8122,7 +8122,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TypertCodec',
declaration: 'export type TypertCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly create: () => TypertSchema;\n readonly decode?: (value: unknown) => unknown;\n readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown;\n} | {\n readonly mode: \'src-json\';\n};',
declaration: 'export type TypertCodec<Value = unknown> = TypertRemoteValue<Value> & {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly decode?: ((value: unknown) => unknown) | undefined;\n readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined;\n} | {\n readonly mode: \'src-json\';\n};',
},
{
name: 'TypertContribution',
@@ -8200,6 +8200,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TypertRemoteService',
declaration: 'export abstract class TypertRemoteService<out T = never> extends Service<T> {\n readonly typertRemote: TypertGatewayBinding<this>;\n}',
},
{
name: 'TypertRemoteValue',
declaration: 'export interface TypertRemoteValue<Value = unknown> {\n readonly create: () => TypertSchema<Value>;\n readonly description?: string | undefined;\n readonly jsonSchema?: Readonly<Record<string, unknown>> | undefined;\n readonly jsonSchemaError?: string | undefined;\n}',
},
{
name: 'TypertSchema',
declaration: 'export interface TypertSchema<Output = unknown> {\n parse(value: unknown): Output;\n}',
+7 -4
View File
@@ -18,8 +18,11 @@
en: 949dec3bb987644a
zh: 0ed333a6067465dc
/deepseek-ai-dsh-typert-generator/use-this-package/publishing-typert-artifacts-from-a-package:
en: b8720f6ac186a8e8
zh: 3e04a4c1bebc2cf4
en: c7078becc8f12609
zh: 4b8838b559ecb677
/deepseek-ai-dsh-typert-generator/use-this-package/ordinary-remote-metadata:
en: 6bb456bb03fc6617
zh: 029407c373b8e6a6
/deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically:
en: 8227097859169cdf
zh: 249790ed62e8558a
@@ -57,8 +60,8 @@
en: 09310605f7d136d4
zh: 3535ec7eba83b90f
/deepseek-ai-dsh-typert-generator/known-limitations-and-deferred-work:
en: f3b1002133fe8736
zh: e8d5fecd10db5e3a
en: fdc6707a82ceb4c3
zh: 91fb8cab9fcf56bc
/deepseek-ai-dsh-typert-generator/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+16
View File
@@ -43,6 +43,21 @@ files:
After the build, `lib/typert.host.js` and `lib/typert.host.d.ts` exist and the [loader](../loader/README.md) registers the contribution in Loader compositions. The generated declaration file exposes `TYPERT` as `unknown`, so contributing packages never depend on the runtime registry. The generator fails the build when a declaration is missing, points at the wrong file, or publishes Remote artifacts without Remote methods; unsupported Zod projections fail with a `TypertEmitError` naming the construct instead of flattening or weakening the source type.
<a id="ordinary-remote-metadata"></a>
### Ordinary Remote metadata
The emitter writes one static `TypertRemoteMethodSpec` per endpoint and calls the pure `createTypertRemoteInfo` factory with the spec and the artifact's own imported Zod runtime. Ordinary consumer `./remote` artifacts retain their default export and `TYPERT_REMOTE`, typed as `TypertRemoteInfo` with precise `TypertRemoteMethod<Input, Output, Mode, Uplink, DecodedOutput>` entries in `TYPERT_REMOTE.methods`. Each entry is the same object as its direct descriptor in `TYPERT_REMOTE.descriptors`; Context-only receivers remain in descriptors and scoped declarations. There is no separate `REMOTE_METHODS` export, nested `metadata`, or second `output` codec. The [protocol](<../protocol/README.md>) owns the factory and method API.
The factory returns a class instance that retains the spec until its `descriptors` or `methods` getter builds their shared index. Each consumer-selected input-policy view, parser, and projection is independently lazy and cached, and generated individual Zod factories also materialize and cache on first use. Ordinary Host `./typert` builds a private info instance whose descriptors are exposed by the `TYPERT.invocations` getter. Host and consumer artifacts share definitions, not object identity across Programs or processes.
Generated declarations use type-only imports for owner and schema types. Importing metadata neither activates the Host Service nor opens a network connection. Framework consumers query the existing [registry](<../registry/README.md>); standalone consumers can import `./remote` directly. Source compositions requiring strict validation prebuild ordinary artifacts and register `./typert` through the [loader](<../loader/README.md>); descriptor-free source discovery cannot recover erased type constraints, documentation, or schemas.
Missing JSDoc and unsupported tool JSON Schema do not block otherwise valid ordinary generation or its strict codecs. Metadata retains parsers and available documentation independently of projection: each unsupported input variant or output projection omits `jsonSchema` and records its own `jsonSchemaError`. A tool consumer explicitly selects methods and requires its documentation and supported input schema; an output parser is required, but output JSON Schema is optional unless that consumer requires it. Metadata alone does not make every Remote method a tool.
Every generated parameter spec contains both `codec` and `strictCodec`, derived from the same checker-resolved type model; the analyzer and model carry no per-method input policy. `method.input()` and `{ strict: false }` select the normal codec used by Host dispatch, whose declared objects accept and strip unknown fields. `method.input({ strict: true })` selects recursively closed declared objects for both parsing and supported JSON Schema. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys remain open, while constrained dictionary values also validate named fields against their index value type. Strict parsing distributes named-field and index-value unions before merging and closing each supported object combination, including optional fields and nullable index values. `result` keeps its single normal codec. Tool projection targets the tool-schema subset that [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) enforces on tool parameters, tool output, and the generated PTC SDK; it supports scalars, objects, arrays, and disjoint unions, while canonical `JsonValue` and string-keyed JSON dictionaries need no recursive references. A type outside that subset is reported instead of projected more weakly. Numeric bounds and other semantic rules absent from TypeScript remain with the owning operation.
`WorkspaceTypertGenerator` analyzes all contributors of each selected face before filtering emitted packages, so a package subset retains the same declaration merges and output bytes as a complete build.
### Analyzing a workspace statically
Static consumers call `WorkspaceAnalyzer` directly against the workspace's `tsconfig.host.json` and `tsconfig.client.json` aggregates, select a face and package subset, and read the resulting `FaceModel` and type graph without emitting or loading runtime artifacts. `analyzeInBatches()` processes a large package selection through bounded compiler programs with the same model shape, and `discoverPackages()` finds contributing packages without building a type-checker program; its optional `package.json` predicate skips a package before its sources or project config are read. Typert parses only the tsconfigs of packages it analyzes; validating every referenced tsconfig belongs to the preceding `tsc -b`.
@@ -142,6 +157,7 @@ These limits define what the generator cannot model or emit; they are current pa
- **Package export patterns are skipped** — contributing packages need concrete export targets; wildcard export patterns are not analyzed.
- **Namespace re-exports across faces fail** — named and star re-exports produce links, but a namespace re-export cannot be represented until `TypeTargetModel` can model a module namespace without flattening it.
- **The Zod emitter supports a deliberate subset** — generic schema declarations and computed constructs such as conditional or mapped schema roots fail until a concrete schema-factory policy exists.
- **Strict indexed object merging has limits** — named-field and index-value unions are combined branch by branch when their object members are concrete properties. Recursive references, nested index signatures, and computed members retain separate schema intersections; strict parsing can reject otherwise valid values whose fields come from different operands.
- **No generated schema imports across faces** — cross-face links are represented for analysis, but no generated schema requires a runtime cross-face Zod import.
- **Discovery covers concrete public exports only** — declarations neither exported nor imported by the reachable graph are intentionally outside the package model.
+16
View File
@@ -43,6 +43,21 @@ files:
构建完成后,`lib/typert.host.js` 与 `lib/typert.host.d.ts` 即存在,[loader](../loader/README.zh.md) 会在 Loader 组合中注册该贡献。生成的声明文件把 `TYPERT` 暴露为 `unknown`,因此参与贡献的包永远不会依赖运行时注册表。当声明缺失、指向错误文件,或在没有 Remote 方法的情况下发布 Remote 产物时,生成器会使构建失败;不支持的 Zod 投影会以 `TypertEmitError` 指明具体构造并失败,而不会展平或弱化源类型。
<a id="ordinary-remote-metadata"></a>
### 普通 Remote 元数据
发射器为每个端点写入一份静态 `TypertRemoteMethodSpec`,并将 spec 和产物自身导入的 Zod 运行时传给纯 `createTypertRemoteInfo` factory。普通消费方 `./remote` 产物保留默认导出和 `TYPERT_REMOTE`,其类型为 `TypertRemoteInfo`,`TYPERT_REMOTE.methods` 中的条目具有精确的 `TypertRemoteMethod<Input, Output, Mode, Uplink, DecodedOutput>` 类型。每个条目就是 `TYPERT_REMOTE.descriptors` 中同一个直接方法描述符对象;仅使用 Context 接收器的方法保留在描述符和作用域声明中。不存在独立的 `REMOTE_METHODS` 导出、嵌套 `metadata` 或第二份 `output` codec。[协议](<../protocol/README.zh.md>)负责 factory 和方法 API。
factory 返回类实例,并保留 spec,直到 `descriptors` 或 `methods` getter 构建两者共享的索引。消费方选择的每种输入策略视图、解析器和投影都独立惰性求值并缓存,生成的独立 Zod factory 同样在首次使用时物化并缓存。普通 Host `./typert` 创建私有 info 实例,由 `TYPERT.invocations` getter 暴露其描述符。Host 与消费方产物共享定义,不承诺跨 Program 或进程的对象身份。
生成的声明对拥有者类型和 schema 类型使用仅类型导入。导入元数据既不会激活 Host Service,也不会打开网络连接。框架内消费方查询现有[注册表](<../registry/README.zh.md>);独立消费方可以直接导入 `./remote`。要求严格校验的源码组合预先构建普通产物,并通过 [loader](<../loader/README.zh.md>) 注册 `./typert`;无描述符的源码发现无法恢复已擦除的类型约束、文档或 schema。
缺少 JSDoc 或不支持工具 JSON Schema,不会阻止其他方面有效的普通生成及其严格 codec。元数据独立于投影保留解析器与可用文档:每个不受支持的输入变体或输出投影都省略 `jsonSchema`,并记录各自的 `jsonSchemaError`。工具消费方显式选择方法,并要求其所需文档和受支持的输入 schema;输出解析器是必需的,但输出 JSON Schema 是可选的,除非该消费方要求提供。仅有元数据不会让每个 Remote 方法自动成为工具。
每个生成参数 spec 都包含从同一个 checker 解析后类型模型派生的 `codec` 和 `strictCodec`;分析器与模型不携带逐方法输入策略。`method.input()` 与 `{ strict: false }` 选择 Host 分发使用的普通 codec,其已声明对象接受并剥离未知字段。`method.input({ strict: true })` 为解析和受支持的 JSON Schema 选择递归关闭的已声明对象。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,具名字典字段也须满足索引值类型的约束。严格解析先展开具名字段与索引值的联合分支,再合并并关闭每个受支持的对象组合,包括可选字段和可空索引值。`result` 保留唯一的普通 codec。工具投影的目标是 [`@deepseek-ai/dsh-tools`](../../core/tools/src/json-schema.ts) 对工具参数、工具输出和生成的 PTC SDK 强制执行的 tool-schema 子集;它支持标量、对象、数组和互斥联合类型,规范的 `JsonValue` 与字符串键 JSON 字典无需递归引用。子集之外的类型会被报告,而不是以更弱的 schema 投影。TypeScript 未表达的数值范围与其他语义规则仍由操作拥有者负责。
`WorkspaceTypertGenerator` 先分析每个选中 face 的所有贡献方,再筛选要生成产物的包,因此包子集保留与完整构建相同的声明合并结果和输出字节。
### 静态分析工作区
静态消费方直接针对工作区的 `tsconfig.host.json` 与 `tsconfig.client.json` 聚合配置调用 `WorkspaceAnalyzer`,选择 face 与包子集,并在不生成或加载运行时产物的前提下读取生成的 `FaceModel` 与类型图。`analyzeInBatches()` 通过有界的编译器程序处理大批量包选择,模型形态保持一致;`discoverPackages()` 无需构建类型检查程序即可找出参与贡献的包,其可选的 `package.json` 谓词会在读取某个包的源码或项目配置之前就跳过该包。Typert 只解析它所分析的包的 tsconfig;校验每个被引用的 tsconfig 由之前运行的 `tsc -b` 负责。
@@ -142,6 +157,7 @@ Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定
- **包导出模式会被跳过**——参与贡献的包需要具体的导出目标;通配符导出模式不会被分析。
- **跨 face 的命名空间重新导出会失败**——具名与星号重新导出会生成链接,但在 `TypeTargetModel` 能够不经展平表示模块命名空间之前,命名空间重新导出无法表示。
- **Zod 生成器只支持有意限定的子集**——泛型 schema 声明,以及以条件类型或映射类型为 schema 根的计算构造,都会失败,直到存在明确的 schema 工厂策略。
- **严格索引对象合并存在限制**——当对象成员都是具体属性时,具名字段与索引值的联合类型会逐分支组合。递归引用、嵌套索引签名和计算成员仍使用独立 schema 的交集;严格解析可能拒绝字段来自不同交集分量的合法值。
- **没有生成的 schema 跨 face 导入**——跨 face 链接会在模型中表示以供分析,但生成的 schema 均不需要跨 face 的运行时 Zod 导入。
- **发现范围只覆盖具体公开导出**——既未导出、也未由可达图导入的声明按设计排除在包模型之外。
+1
View File
@@ -39,6 +39,7 @@
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-tool-cordis": "workspace:*",
"@deepseek-ai/dsh-typert-registry": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
+39 -13
View File
@@ -1143,6 +1143,7 @@ class FaceAnalyzer {
if ((method.typeParameters?.length ?? 0) > 0) {
this.fail(method, 'generic Remote methods are not supported')
}
const documentation = documentationOf(method)
const methodName = method.name.text
const exportedMethod = invocation.exportName ?? methodName
@@ -1212,7 +1213,8 @@ class FaceAnalyzer {
}
if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`)
wires.add(modeled.wire)
parameters.push(modeled)
const description = documentation.tags.find(tag => tag.name === 'param' && tag.argument === modeled.name)?.comment?.replace(/^[-—–]\s*/, '')
parameters.push({ ...modeled, ...(description === undefined ? {} : { description }) })
}
let receiver: InvocationModel['invocation'] = { kind: 'direct' }
@@ -1271,6 +1273,7 @@ class FaceAnalyzer {
),
}
return {
...documentation,
id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
service: binding.service,
namespace: binding.namespace,
@@ -1406,16 +1409,20 @@ class FaceAnalyzer {
}
marker = { kind: 'direct', exportName }
} else {
if (!ts.isObjectLiteralExpression(argument) || argument.properties.length !== 1) {
this.fail(argument, 'Remote() options must contain exactly mode: "stream"')
if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) {
this.fail(argument, 'Remote() options require name or mode: "stream"')
}
marker = { kind: 'direct' }
for (const property of argument.properties) {
if (!ts.isPropertyAssignment(property)) this.fail(property, 'Remote() options must use named values')
const name = memberName(property.name)
if (name === 'mode' && stringLiteralValue(property.initializer) === 'stream') marker = { ...marker, mode: 'stream' }
else if (name === 'name') {
const value = stringLiteralValue(property.initializer)
if (value === undefined || !isRemoteSegment(value)) this.fail(property, 'Remote() name must be an endpoint segment')
marker = { ...marker, exportName: value }
} else this.fail(property, 'Remote() options require name or mode: "stream"')
}
const [property] = argument.properties
if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"')
const mode = ts.isPropertyAssignment(property) && memberName(property.name) === 'mode'
? stringLiteralValue(property.initializer)
: undefined
if (mode !== 'stream') this.fail(property, 'Remote() options must contain exactly mode: "stream"')
marker = { kind: 'direct', mode }
}
} else if (ts.isCallExpression(expression)
&& this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) {
@@ -1682,6 +1689,12 @@ class FaceAnalyzer {
completed.set(type, id)
return id
}
const alias = type.aliasSymbol
const aliasDeclaration = alias === undefined ? undefined : preferredDeclaration(this.resolveSymbol(alias))
if (alias?.name === 'JsonValue' && aliasDeclaration !== undefined
&& this.registrationForFile(aliasDeclaration.getSourceFile().fileName)?.name === '@deepseek-ai/dsh-util-values') {
return add({ kind: 'keyword', name: 'unknown', json: true })
}
const flags = type.flags
if (this.isRemoteByteArray(type)) {
return add({ kind: 'reference', name: 'Uint8Array', target: { kind: 'standard', name: 'Uint8Array' }, arguments: [] })
@@ -1714,9 +1727,22 @@ class FaceAnalyzer {
const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true'
return add({ kind: 'literal', value, text: String(value) })
}
if (type.isUnionOrIntersection()) {
if (type.isIntersection()) {
const material = type.types.filter(member => !this.isRemotePhantomConstraint(member))
if (material.length === 1) {
const projected = convert(material[0] as ts.Type)
const { id: _projectedId, ...value } = this.nodes.get(projected) as TypeNodeModel
return add(value)
}
// Object intersections contribute one request object; closing each operand
// separately would reject fields declared by its sibling operands.
if (!material.every(member => (member.flags & ts.TypeFlags.Object) !== 0
&& !this.checker.isArrayType(member) && !this.checker.isTupleType(member))) {
return add({ kind: 'intersection', types: material.map(convert) })
}
} else if (type.isUnion()) {
return add({
kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection',
kind: 'union',
types: type.types.map(convert),
})
}
@@ -1761,7 +1787,7 @@ class FaceAnalyzer {
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType)
const symbolKey = property.getName()
members.push({
...EMPTY_DOCUMENTATION,
...(declaration === undefined ? EMPTY_DOCUMENTATION : documentationOf(declaration)),
id: `${id}#${symbolKey}`,
name: symbolKey,
...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}),
+164 -58
View File
@@ -23,6 +23,7 @@ import type {
} from './model.ts'
import { TypeGraphRenderer } from './renderer.ts'
import { childTypeNodeIds } from './model.ts'
import { projectToolSchema, ToolSchemaError } from './tool-schema.ts'
/** Failure to project a modeled construct into an emitted artifact. */
export class TypertEmitError extends Error {
@@ -107,12 +108,11 @@ export class FaceModelEmitter {
if (packageModel === undefined) {
throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`)
}
const schemas = new SchemaEmitter(
const schemaArtifact = invocationSchemas(
this.renderer,
packageModel.schemas,
invocationBoundaryRoots(packageModel.invocations),
)
const schemaArtifact = schemas.emit()
const runtimeModel = this.runtimeModel(packageModel)
const js = this.renderJs(packageModel, schemaArtifact, runtimeModel)
const dts = this.renderDts(packageModel, schemaArtifact)
@@ -193,10 +193,14 @@ export class FaceModelEmitter {
'/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */',
]
if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '')
if (packageModel.invocations.length > 0) {
lines.push("import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info'", '')
}
lines.push(...schemas.definitions)
if (schemas.definitions.length > 0) lines.push('')
for (const schema of schemas.exports) lines.push(`export const ${schema.exportName} = ${schema.internalName}`)
if (schemas.exports.length > 0) lines.push('')
if (packageModel.invocations.length > 0) lines.push(`const $remote = ${this.remoteInfoLiteral(packageModel, schemas)}`, '')
const model = JSON.stringify(runtimeModel, null, 2)
lines.push('export const TYPERT = {')
lines.push(` package: ${quote(packageModel.name)},`)
@@ -206,11 +210,8 @@ export class FaceModelEmitter {
lines.push(` { name: ${quote(schema.exportName)}, create: ${schema.exportName} },`)
}
lines.push(' ],')
lines.push(' invocations: [')
for (const invocation of packageModel.invocations) {
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
}
lines.push(' ],')
if (packageModel.invocations.length > 0) lines.push(' get invocations() { return $remote.descriptors },')
else lines.push(' invocations: [', ' ],')
lines.push(` model: ${indent(model, 2).trimStart()},`)
lines.push('}')
return `${lines.join('\n')}\n`
@@ -244,41 +245,60 @@ export class FaceModelEmitter {
}
private emitRemote(packageModel: PackageModel): RemoteModelEmitResult {
const schemas = new SchemaEmitter(
const schemas = invocationSchemas(
this.renderer,
[],
invocationBoundaryRoots(packageModel.invocations),
).emit()
)
const lines = [
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
]
if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '')
lines.push("import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info'", '')
lines.push(...schemas.definitions)
if (schemas.definitions.length > 0) lines.push('')
lines.push('export const TYPERT_REMOTE = {')
lines.push(` package: ${quote(packageModel.name)},`)
lines.push(' descriptors: [')
for (const invocation of packageModel.invocations) {
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
}
lines.push(' ],')
lines.push('}')
lines.push('')
lines.push(`export const TYPERT_REMOTE = ${this.remoteInfoLiteral(packageModel, schemas)}`, '')
lines.push('export default TYPERT_REMOTE')
const declaration = this.renderRemoteDts(packageModel)
return {
js: `${lines.join('\n')}\n`,
...declaration,
...this.renderRemoteDts(packageModel),
}
}
private remoteMethodTypes(packageModel: PackageModel): string {
const names = allocateRemoteImportNames(remoteImports(packageModel.invocations))
const entries = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct').map((invocation) => {
const input = this.remoteInputType(invocation, names)
const result = this.renderer.renderType(invocation.result.type, names)
const uplink = invocation.uplink === undefined ? '' : `, ${this.renderer.renderType(invocation.uplink.boundary.type, names)}`
const mode = invocation.mode === 'stream' ? `, ${quote('stream')}${uplink}`
: containsBytes(this.renderer, invocation.result.codecType) ? `, 'unary', never, RemoteDecoded<${result}>` : ''
return ` readonly ${quote(`${invocation.namespace}/${invocation.method}`)}: TypertRemoteMethod<${input}, ${result}${mode}>`
})
return `{\n${entries.join('\n')}\n}`
}
private remoteInputType(invocation: InvocationModel, names: ReadonlyMap<SymbolId, string>): string {
return `{ ${invocation.parameters.map(parameter => `readonly ${quote(parameter.wire)}${parameter.boundary.acceptsUndefined ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, names)}`).join('; ')} }`
}
private remoteInfoLiteral(packageModel: PackageModel, schemas: SchemaArtifact): string {
return [
'createTypertRemoteInfo({',
` package: ${quote(packageModel.name)},`,
' methods: {',
...packageModel.invocations.map(invocation =>
` ${quote(`${invocation.namespace}/${invocation.method}`)}: ${indent(this.invocationLiteral(invocation, schemas), 4).trimStart()},`),
' },',
'}, z)',
].join('\n')
}
private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string {
const lines = [
'{',
` id: ${quote(invocation.id)},`,
` service: ${quote(invocation.service)},`,
` namespace: ${quote(invocation.namespace)},`,
` method: ${quote(invocation.method)},`,
...(invocation.description === undefined ? [] : [` description: ${quote(invocation.description)},`]),
]
if (invocation.implementation !== undefined) {
lines.push(` implementation: ${quote(invocation.implementation)},`)
@@ -307,14 +327,21 @@ export class FaceModelEmitter {
invocation.parameters.forEach((parameter, index) => {
lines.push(' {')
lines.push(` name: ${quote(parameter.name)},`)
lines.push(` wire: ${quote(parameter.wire)},`)
if (parameter.wire !== parameter.name) lines.push(` wire: ${quote(parameter.wire)},`)
if (parameter.description !== undefined) lines.push(` description: ${quote(parameter.description)},`)
lines.push(` source: ${quote(parameter.source)},`)
if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`)
if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,')
lines.push(` codec: ${indent(strictCodec(
parameter.boundary,
schemas.boundary(parameterBoundaryKey(invocation, index)),
), 6).trimStart()},`)
for (const strict of [false, true]) {
lines.push(` ${strict ? 'strictCodec' : 'codec'}: ${indent(strictCodec(
parameter.boundary,
schemas.boundary(parameterBoundaryKey(invocation, index, strict)),
undefined,
toolProjection(() => projectToolSchema(
this.renderer, parameter.boundary.codecType, strict, parameter.boundary.acceptsUndefined,
)),
), 6).trimStart()},`)
}
lines.push(' },')
})
lines.push(' ],')
@@ -329,10 +356,13 @@ export class FaceModelEmitter {
if (invocation.cancellation !== undefined) {
lines.push(" cancellation: { parameter: 'signal' },")
}
const returns = invocation.tags.find(tag => tag.name === 'returns' || tag.name === 'return')?.comment?.replace(/^[-—–]\s*/, '')
lines.push(` result: ${indent(strictCodec(
invocation.result,
schemas.boundary(resultBoundaryKey(invocation)),
schemas.encoder(resultBoundaryKey(invocation)),
toolProjection(() => projectToolSchema(this.renderer, invocation.result.codecType)),
returns,
), 2).trimStart()},`)
lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`)
lines.push('}')
@@ -356,9 +386,11 @@ export class FaceModelEmitter {
const lines = [
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
'import type {',
...packageModel.invocations.some(invocation => containsBytes(this.renderer, invocation.result.codecType)) ? [' RemoteDecoded,'] : [],
' RemoteResult,',
...packageModel.invocations.some(invocation => invocation.mode === 'stream') ? [' RemoteStreamHandle,'] : [],
' TypertRemoteContribution,',
' TypertRemoteInfo,',
...packageModel.invocations.some(invocation => invocation.invocation.kind === 'direct') ? [' TypertRemoteMethod,'] : [],
'} from \'@deepseek-ai/dsh-typert-protocol\'',
]
const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' })
@@ -368,12 +400,6 @@ export class FaceModelEmitter {
lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`)
}
lines.push('')
if (packageModel.invocations.some(invocation => containsBytes(this.renderer, invocation.result.codecType))) {
lines.push('type RemoteDecoded<Value> = Value extends Uint8Array ? Uint8Array<ArrayBuffer>')
lines.push(' : Value extends string | number | boolean | null | undefined ? Value')
lines.push(' : { [Key in keyof Value]: RemoteDecoded<Value[Key]> }')
lines.push('')
}
lines.push('declare module \'@deepseek-ai/dsh-typert-protocol\' {')
const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct')
const scoped = packageModel.invocations.filter(invocation =>
@@ -406,7 +432,7 @@ export class FaceModelEmitter {
}
lines.push('}')
lines.push('')
lines.push('export declare const TYPERT_REMOTE: TypertRemoteContribution')
lines.push(`export declare const TYPERT_REMOTE: TypertRemoteInfo<${this.remoteMethodTypes(packageModel)}>`)
lines.push('export default TYPERT_REMOTE')
lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map')
return {
@@ -536,6 +562,31 @@ interface SchemaArtifact {
interface BoundarySchemaRoot {
readonly key: string
readonly type: TypeNodeId
/** Reject unknown fields in declared objects reached from this root. */
readonly closedObjects?: true
}
function toolProjection(project: () => Record<string, unknown>): string {
try {
return `jsonSchema: ${JSON.stringify(project())}`
} catch (error) {
if (!(error instanceof ToolSchemaError)) throw error
return `jsonSchemaError: ${quote(error.message)}`
}
}
function invocationSchemas(
renderer: TypeGraphRenderer, schemas: readonly SchemaModel[], roots: readonly BoundarySchemaRoot[],
): SchemaArtifact {
const identifiers = new Set<string>()
const ordinary = new SchemaEmitter(renderer, schemas, roots.filter(root => !root.closedObjects), {}, identifiers).emit()
const closedRoots = roots.filter(root => root.closedObjects)
const closed = new SchemaEmitter(renderer, [], closedRoots, { closedObjects: true }, identifiers).emit()
const keys = new Set(closedRoots.map(root => root.key))
return { definitions: [...ordinary.definitions, ...closed.definitions], exports: ordinary.exports,
boundary: key => keys.has(key) ? closed.boundary(key) : ordinary.boundary(key),
encoder: key => keys.has(key) ? closed.encoder(key) : ordinary.encoder(key),
}
}
class ResultEncoderEmitter {
@@ -659,6 +710,8 @@ class SchemaEmitter {
private readonly renderer: TypeGraphRenderer,
private readonly schemas: readonly SchemaModel[],
private readonly boundaries: readonly BoundarySchemaRoot[],
private readonly options: { readonly closedObjects?: boolean } = {},
identifiers = new Set<string>(),
) {
const declarations = new Map<SymbolId, TypeDeclarationModel>()
for (const schema of schemas) {
@@ -672,9 +725,8 @@ class SchemaEmitter {
}
}
this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id))
const identifiers = new Set<string>()
for (const declaration of this.declarations) {
const base = `${safeIdentifier(declaration.name)}$schema`
const base = `${options.closedObjects ? 'closed$' : ''}${safeIdentifier(declaration.name)}$schema`
let name = base
let suffix = 2
while (identifiers.has(name)) name = `${base}${String(suffix++)}`
@@ -699,7 +751,7 @@ class SchemaEmitter {
const encode = encoder.reference(boundary.type)
if (encode !== undefined) encoders.set(boundary.key, encode)
const name = this.boundaryName(boundary.key)
definitions.push(`let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.typeSchema(boundary.type)})`)
definitions.push(this.cachedFactory(name, this.typeSchema(boundary.type)))
}
definitions.push(...encoder.definitions())
const exports = this.schemas.map((model): SchemaExport => ({
@@ -718,7 +770,7 @@ class SchemaEmitter {
private declarationDefinition(declaration: TypeDeclarationModel): string {
const name = this.schemaName(declaration.id)
if (declaration.typeParameters.length === 0) {
return `let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.declarationSchema(declaration, new Map())})`
return this.cachedFactory(name, this.declarationSchema(declaration, new Map()))
}
const parameters = declaration.typeParameters.map((parameter, index) =>
[`type${String(index)}$schema`, parameter.id] as const)
@@ -726,6 +778,10 @@ class SchemaEmitter {
return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}`
}
private cachedFactory(name: string, expression: string): string {
return `let ${name}$value\nconst ${name} = () => (${name}$value ??= ${expression})`
}
private declarationSchema(
declaration: TypeDeclarationModel,
substitutions: ReadonlyMap<string, string>,
@@ -747,6 +803,7 @@ class SchemaEmitter {
private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string> = new Map()): string {
const node = this.renderer.node(id)
if (node.json) return 'z.json()'
switch (node.kind) {
case 'keyword': return this.keywordSchema(node.name)
case 'literal': return `z.literal(${node.text})`
@@ -880,8 +937,10 @@ class SchemaEmitter {
subject: string,
substitutions: ReadonlyMap<string, string>,
): string {
const properties: string[] = []
const properties: Extract<MemberModel, { kind: 'property' }>[] = []
const indices: string[] = []
let stringIndexValue: string | undefined
let stringIndexType: TypeNodeId | undefined
let symbolMembers = 0
for (const member of members) {
if (member.static || member.visibility !== 'public') continue
@@ -897,32 +956,75 @@ class SchemaEmitter {
if (member.signature.parameters.length !== 1 || parameter === undefined) {
this.fail(subject, 'index signature must have exactly one key parameter')
}
indices.push(this.readonly(
`z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`,
member.readonly,
))
const key = this.typeSchema(parameter.type, substitutions)
const value = this.typeSchema(member.signature.returns, substitutions)
if (key === 'z.string()') {
stringIndexValue = value
stringIndexType = member.signature.returns
}
indices.push(this.readonly(`z.record(${key}, ${value})`, member.readonly))
continue
}
if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
// Freezing a union may freeze its populated Uint8Array member.
const schema = this.readonly(this.typeSchema(member.type, substitutions),
member.readonly && !containsBytes(this.renderer, member.type, false))
const property = this.describe(
this.optional(schema, member.optional),
member,
)
properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`)
properties.push(member)
}
if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature')
// A unique-symbol-only object is a compile-time marker and imposes no JSON shape.
if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()'
const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
const fields = properties.map((member) => {
// Freezing a union may freeze its populated Uint8Array member.
const type = this.options.closedObjects && stringIndexType !== undefined
? this.indexedPropertySchema(member.type, stringIndexType, substitutions)
: this.typeSchema(member.type, substitutions)
const schema = this.readonly(type,
member.readonly && !containsBytes(this.renderer, member.type, false))
return `${quote(member.jsonName ?? member.name)}: ${this.describe(this.optional(schema, member.optional), member)}`
})
const object = `z.${this.options.closedObjects && indices.length === 0 ? 'strictObject' : 'object'}({${fields.length === 0 ? '' : `\n${fields.map(field => ` ${field},`).join('\n')}\n`}})`
const index = indices[0]
if (index === undefined) return object
if (properties.length === 0) return index
// Named keys enforce both their declared type and the index value; the catchall checks other keys.
if (this.options.closedObjects && stringIndexValue !== undefined) return `${object}.catchall(${stringIndexValue})`
return `z.intersection(${object}, ${index})`
}
private indexedPropertySchema(
declared: TypeNodeId,
indexed: TypeNodeId,
substitutions: ReadonlyMap<string, string>,
): string {
if (declared === indexed) return this.typeSchema(declared, substitutions)
const own = this.renderer.node(declared)
const value = this.renderer.node(indexed)
if (own.kind === 'union') {
return `z.union([${own.types.map(type => this.indexedPropertySchema(type, indexed, substitutions)).join(', ')}])`
}
if (value.kind === 'union') {
return `z.union([${value.types.map(type => this.indexedPropertySchema(declared, type, substitutions)).join(', ')}])`
}
const ownProperties = own.kind === 'object'
? own.members.filter((member): member is Extract<MemberModel, { kind: 'property' }> => member.kind === 'property') : []
const valueProperties = value.kind === 'object'
? value.members.filter((member): member is Extract<MemberModel, { kind: 'property' }> => member.kind === 'property') : []
if (own.kind === 'object' && value.kind === 'object' && !own.json && !value.json
&& ownProperties.length === own.members.length && valueProperties.length === value.members.length
&& [...ownProperties, ...valueProperties].every(member => !member.static
&& member.visibility === 'public' && member.computed === undefined)) {
const indexedFields = new Map(valueProperties.map(member => [member.jsonName ?? member.name, member]))
const overlaps = ownProperties.flatMap((member) => {
const inherited = indexedFields.get(member.jsonName ?? member.name)
if (inherited === undefined) return []
const combined = this.indexedPropertySchema(member.type, inherited.type, substitutions)
const schema = this.readonly(this.optional(combined, member.optional && inherited.optional),
(member.readonly || inherited.readonly) && !containsBytes(this.renderer, member.type, false))
return [`${quote(member.jsonName ?? member.name)}: ${this.describe(schema, member)}`]
})
return `z.strictObject({ ...${this.typeSchema(indexed, substitutions)}.shape, ...${this.typeSchema(declared, substitutions)}.shape${overlaps.length === 0 ? '' : `, ${overlaps.join(', ')}`} })`
}
return `z.intersection(${this.typeSchema(declared, substitutions)}, ${this.typeSchema(indexed, substitutions)})`
}
private exportSchemaName(model: SchemaModel): string {
const name = this.schemaName(model.symbol)
const declaration = this.renderer.declaration(model.symbol)
@@ -999,6 +1101,7 @@ function invocationBoundaryRoots(invocations: readonly InvocationModel[]): Bound
}
invocation.parameters.forEach((parameter, index) => {
result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType })
result.push({ key: parameterBoundaryKey(invocation, index, true), type: parameter.boundary.codecType, closedObjects: true })
})
if (invocation.uplink !== undefined) {
result.push({ key: uplinkBoundaryKey(invocation), type: invocation.uplink.boundary.codecType })
@@ -1016,8 +1119,8 @@ function uplinkBoundaryKey(invocation: InvocationModel): string {
return `${invocation.id}:uplink`
}
function parameterBoundaryKey(invocation: InvocationModel, index: number): string {
return `${invocation.id}:parameter:${String(index)}`
function parameterBoundaryKey(invocation: InvocationModel, index: number, strict = false): string {
return `${invocation.id}:parameter:${String(index)}${strict ? ':strict' : ''}`
}
function resultBoundaryKey(invocation: InvocationModel): string {
@@ -1048,12 +1151,14 @@ function containsBytes(
return childTypeNodeIds(node).some(child => containsBytes(renderer, child, nested, visited))
}
function strictCodec(boundary: RemoteBoundaryModel, schema: string, encoder?: string): string {
function strictCodec(boundary: RemoteBoundaryModel, schema: string, encoder?: string, projection?: string, description?: string): string {
return [
'{',
' mode: \'strict\',',
` typeSymbol: ${quote(boundary.typeSymbol)},`,
` create: ${schema},`,
...(description === undefined ? [] : [` description: ${quote(description)},`]),
...(projection === undefined ? [] : [` ${projection},`]),
...(encoder === undefined ? [] : [
` decode: value => ${schema}().parse(value),`,
` encode: (value, writeBytes) => ${encoder}(value, writeBytes, [], new Map()),`,
@@ -1089,7 +1194,8 @@ function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): R
'RemoteDecoded',
'RemoteResult',
'RemoteStreamHandle',
'TypertRemoteContribution',
'TypertRemoteInfo',
'TypertRemoteMethod',
'TYPERT_REMOTE',
])
const names = new Map<SymbolId, string>()
+7 -2
View File
@@ -122,10 +122,11 @@ export interface InvocationParameterModel {
/** Authored as an optional parameter, so consumers may omit the wire field. */
readonly optional?: true
readonly boundary: RemoteBoundaryModel
readonly description?: string
}
/** One strictly analyzed Host method exported through Typert Gateway. */
export interface InvocationModel {
export interface InvocationModel extends DocumentationModel {
readonly id: string
readonly service: string
readonly namespace: string
@@ -352,7 +353,7 @@ export interface TemplateSpanModel {
}
/** Compiler-independent TypeScript type expression. */
export type TypeNodeModel =
export type TypeNodeModel = (
| { readonly id: TypeNodeId; readonly kind: 'keyword'; readonly name: KeywordTypeName }
| { readonly id: TypeNodeId; readonly kind: 'literal'; readonly value: string | number | bigint | boolean | null; readonly text: string }
| { readonly id: TypeNodeId; readonly kind: 'parenthesized'; readonly type: TypeNodeId }
@@ -390,6 +391,10 @@ export type TypeNodeModel =
}
| { readonly id: TypeNodeId; readonly kind: 'predicate'; readonly asserts: boolean; readonly parameter: string; readonly type?: TypeNodeId }
| { readonly id: TypeNodeId; readonly kind: 'this' }
) & {
/** Canonical lossless JsonValue, represented without recursive tool-schema references. */
readonly json?: true
}
/**
* Return the direct type-expression edges owned by one node.
@@ -0,0 +1,152 @@
/** Lossless projection into the harness tool-schema subset. */
import type { MemberModel, TypeNodeId } from './model.ts'
import { TypeGraphRenderer } from './renderer.ts'
/** A resolved codec whose type the tool-schema subset cannot represent without weakening constraints. */
export class ToolSchemaError extends Error {
override name = 'ToolSchemaError'
}
interface Schema extends Record<string, unknown> {
type?: string
const?: string | number | boolean | null
enum?: (string | number | boolean | null)[]
oneOf?: Schema[]
properties?: Record<string, Schema>
required?: string[]
additionalProperties?: boolean
items?: Schema
description?: string
}
/**
* Project one resolved type into the tool-schema subset that `@deepseek-ai/dsh-tools`
* enforces on tool parameters, tool output, and the generated PTC SDK. A type the
* subset cannot express without weakening constraints raises {@link ToolSchemaError},
* which callers report as an absent projection rather than a narrower schema.
* @param renderer - complete analyzed type graph.
* @param type - resolved wire type.
* @param closedObjects - close declared request objects with `additionalProperties: false`.
* @param optional - omit a top-level undefined alternative supplied by an optional argument.
* @returns an acyclic schema in the tool-schema subset.
*/
export function projectToolSchema(
renderer: TypeGraphRenderer, type: TypeNodeId, closedObjects = false, optional = false,
): Record<string, unknown> {
const active = new Set<TypeNodeId>()
const fail = (subject: string): never => { throw new ToolSchemaError(`typert tool schema: ${subject}`) }
const project = (id: TypeNodeId, optional = false): Schema => {
const node = renderer.node(id)
if (node.json) return {}
if (active.has(id)) return fail('recursive selected type requires a supported JSON value declaration')
active.add(id)
try {
switch (node.kind) {
case 'keyword': {
if (['string', 'number', 'boolean'].includes(node.name)) return { type: node.name }
return fail(`unsupported ${node.name} value`)
}
case 'literal': {
if (typeof node.value === 'bigint') return fail('bigint is not JSON')
return { type: node.value === null ? 'null' : typeof node.value, const: node.value }
}
case 'parenthesized': return project(node.type, optional)
case 'array': return { type: 'array', items: project(node.element) }
case 'object': return object(node.members)
case 'reference': {
if (node.target.kind !== 'declaration' || node.arguments.length !== 0) {
return fail(`unsupported selected reference ${node.name}`)
}
const declaration = renderer.declaration(node.target.symbol)
const schema = declaration.kind === 'alias' && declaration.type !== undefined
? project(declaration.type, optional)
: object(declaration.members)
if (declaration.extends.length > 0) return fail('unresolved inherited selected type')
return declaration.description === undefined ? schema : { ...schema, description: declaration.description }
}
case 'union': {
const branches = node.types.filter((item) => {
const branch = renderer.node(item)
return !(optional && branch.kind === 'keyword' && branch.name === 'undefined')
}).map(item => project(item))
return union(branches)
}
default: return fail(`unsupported selected ${node.kind} type`)
}
} finally {
active.delete(id)
}
}
const object = (members: readonly MemberModel[]): Schema => {
const properties: Record<string, Schema> = {}
const required: string[] = []
let indexed = false
for (const member of members) {
if (member.static || member.visibility !== 'public' || member.computed === 'symbol') continue
if (member.kind === 'index') {
const key = member.signature.parameters[0]
if (key === undefined || project(key.type).type !== 'string'
|| Object.keys(project(member.signature.returns)).length !== 0) {
return fail('selected dictionaries require string keys and unconstrained JSON values')
}
indexed = true
continue
}
if (member.kind !== 'property' || member.computed === 'dynamic') return fail(`unsupported member ${member.name}`)
const name = member.jsonName ?? member.name
const schema = project(member.type, member.optional)
properties[name] = member.description === undefined ? schema : { ...schema, description: member.description }
if (!member.optional) required.push(name)
}
return { type: 'object', properties, required, additionalProperties: indexed || !closedObjects }
}
const union = (input: Schema[]): Schema => {
if (input.some(branch => Object.keys(branch).length === 0)) return {}
const branches: Schema[] = []
for (const branch of input) {
const scalar = branch.type !== undefined && !['object', 'array'].includes(branch.type)
const previous = scalar ? branches.find(candidate => candidate.type === branch.type) : undefined
if (previous === undefined) { branches.push(branch); continue }
const values = literals(previous)
const next = literals(branch)
if (values === undefined || next === undefined) {
delete previous.const
delete previous.enum
} else {
delete previous.const
previous.enum = [...new Set([...values, ...next])]
}
}
if (branches.length === 1) return branches[0] as Schema
if (branches.length === 0) return fail('empty selected union')
for (const [index, left] of branches.entries()) {
for (const right of branches.slice(index + 1)) {
if (!disjoint(left, right)) return fail('selected union branches overlap or have no required discriminator')
}
}
return { oneOf: branches }
}
return project(type, optional)
}
function literals(schema: Schema): (string | number | boolean | null)[] | undefined {
if (schema.const !== undefined) return [schema.const]
return schema.enum
}
function disjoint(left: Schema, right: Schema): boolean {
if (left.oneOf !== undefined) return left.oneOf.every(branch => disjoint(branch, right))
if (right.oneOf !== undefined) return right.oneOf.every(branch => disjoint(left, branch))
if (left.type !== right.type) return left.type !== undefined && right.type !== undefined
if (left.type !== 'object' || right.type !== 'object') return false
return (left.required ?? []).some((name) => {
if (!right.required?.includes(name)) return false
const a = left.properties?.[name]
const b = right.properties?.[name]
if (a === undefined || b === undefined) return false
const first = literals(a)
const second = literals(b)
return first !== undefined && second !== undefined && first.every(value => !second.includes(value))
})
}
+5 -3
View File
@@ -70,15 +70,16 @@ export class WorkspaceTypertGenerator {
/**
* Generate all discovered contributors, or an explicit package subset.
* @param packages - optional exact package names for a focused pass.
* @param packages - optional exact package names to emit; every pass includes the same face-wide declaration merges.
* @param faces - optional independent program faces to analyze.
* @returns one artifact per package face.
*/
generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] {
const selected = packages ?? this.discover(faces).map(candidate => candidate.package)
const contributors = this.discover(faces).map(candidate => candidate.package)
const selected = new Set(packages ?? contributors)
const workspace = new WorkspaceAnalyzer({
root: this.root,
packages: selected,
packages: contributors,
caches: this.caches,
...(faces === undefined ? {} : { faces }),
...(this.options.checkDiagnostics === undefined ? {} : { checkDiagnostics: this.options.checkDiagnostics }),
@@ -88,6 +89,7 @@ export class WorkspaceTypertGenerator {
for (const face of workspace.faces) {
const emitter = new FaceModelEmitter(face)
for (const packageModel of face.packages) {
if (!selected.has(packageModel.name)) continue
const artifact = {
...emitter.emit(packageModel.name),
packageRoot: packageModel.root,
@@ -23,6 +23,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: RemoteFailure }
export type RemoteDecoded<Value> = Value extends Uint8Array ? Uint8Array<ArrayBuffer>
: Value extends string | number | boolean | bigint | symbol ? Value
: Value extends object ? { [Key in keyof Value]: RemoteDecoded<Value[Key]> } : Value
export type TypertRemoteNamespace<Namespace extends string> = {
[Endpoint in keyof TypertRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
? Method
@@ -30,10 +34,77 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
}
export interface TypertRemoteNamespaceMap {}
export interface TypertClientRemote extends TypertRemoteNamespaceMap {}
export interface TypertRemoteValue<Value = unknown> {
readonly create: () => { parse(value: unknown): Value }
readonly description?: string
readonly jsonSchema?: Readonly<Record<string, unknown>>
readonly jsonSchemaError?: string
}
export type TypertCodec<Value = unknown> =
| TypertRemoteValue<Value> & {
readonly mode: 'strict'
readonly typeSymbol: string
readonly decode?: (value: unknown) => unknown
readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown
}
| { readonly mode: 'src-json' }
export interface InvocationParameterDescriptor {
readonly name: string
readonly wire: string
readonly source: 'json' | 'lookup'
readonly lookup?: string
readonly acceptsUndefined?: true
readonly description?: string
readonly codec: TypertCodec
}
export interface InvocationDescriptor {
readonly id: string
readonly service: string
readonly namespace: string
readonly method: string
readonly implementation?: string
readonly mode?: 'stream'
readonly invocation:
| { readonly kind: 'direct' }
| { readonly kind: 'context'; readonly context: string; readonly wire: string; readonly codec: TypertCodec }
readonly scope?: { readonly context: string; readonly wire: string }
readonly parameters: readonly InvocationParameterDescriptor[]
readonly uplink?: { readonly codec: TypertCodec }
readonly cancellation?: { readonly parameter: 'signal' }
readonly result: TypertCodec
readonly description?: string
readonly sourceLocation?: { readonly file: string; readonly line: number; readonly column: number }
}
export interface TypertRemoteInputOptions {
readonly strict?: boolean
}
export type TypertRemoteMethod<Input extends object, Output, Mode extends 'unary' | 'stream' = 'unary', Uplink = never, DecodedOutput = Output> =
Omit<InvocationDescriptor, 'mode' | 'invocation' | 'result'>
& (Mode extends 'stream' ? { readonly mode: 'stream' } : { readonly mode?: never }) & {
readonly invocation: { readonly kind: 'direct' }
input(options?: TypertRemoteInputOptions): TypertRemoteValue<Input>
readonly result: Extract<TypertCodec<Output>, { readonly mode: 'strict' }>
invoke(remote: TypertClientRemote, input: Input, signal?: AbortSignal): Mode extends 'stream'
? RemoteStreamHandle<Output, Uplink> : Promise<RemoteResult<DecodedOutput>>
}
export interface TypertRemoteContribution {
readonly package: string
readonly descriptors: readonly unknown[]
readonly descriptors: readonly InvocationDescriptor[]
}
export interface TypertRemoteInfo<Methods extends Readonly<Record<string,
TypertRemoteMethod<object, unknown> | TypertRemoteMethod<object, unknown, 'stream', unknown>
>> = Readonly<Record<string, TypertRemoteMethod<object, unknown> | TypertRemoteMethod<object, unknown, 'stream', unknown>>>>
extends TypertRemoteContribution {
readonly methods: Methods
}
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly uplinkItem?: In }
@@ -68,7 +139,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void
export function Remote(option: string | { readonly mode: 'stream' }):
export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string }):
<This extends object, Args extends unknown[], Result>(
method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
@@ -0,0 +1,970 @@
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import ts from 'typescript'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
import { WorkspaceTypertGenerator } from '../src/workspace.ts'
import { executableRemoteModule } from './remote-module.ts'
const roots: string[] = []
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
interface RuntimeValue {
create(): { parse(value: unknown): unknown }
readonly description?: string
readonly jsonSchema?: Record<string, unknown>
readonly jsonSchemaError?: string
}
interface RuntimeDescriptor {
readonly id: string
readonly mode?: 'stream'
readonly description?: string
readonly parameters: readonly {
readonly name: string
readonly wire: string
readonly description?: string
readonly codec: RuntimeValue
}[]
readonly result: RuntimeValue
}
interface RuntimeMethod extends RuntimeDescriptor {
input(options?: { readonly strict?: boolean }): RuntimeValue
invoke(remote: object, input: object, signal?: AbortSignal): unknown
}
interface Generated {
readonly TYPERT_REMOTE: {
readonly descriptors: readonly RuntimeDescriptor[]
readonly methods: Record<string, RuntimeMethod>
}
}
function editFile(root: string, path: string, edit: (source: string) => string): void {
const file = join(root, path)
const source = readFileSync(file, 'utf8')
const result = edit(source)
if (result === source) throw new Error(`fixture edit made no change to ${path}`)
writeFileSync(file, result)
}
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-remote-metadata-'))
roots.push(root)
cpSync(resolve(import.meta.dirname, 'fixtures/remote-model'), root, { recursive: true })
editFile(root, 'packages/remote/src/index.ts', source => source.replace(' @Remote\n', ` /**
* Create a named goal.
* @param agent - owning Agent.
* @param request - goal fields.
* @param signal - request lifetime.
* @returns the created reference.
*/
@Remote
`))
editFile(root, 'packages/remote/src/types.ts', source => source.replace(
' readonly title: string', ' /** Human-readable goal name. */\n readonly title: string',
))
return root
}
const executable = executableRemoteModule<Generated>
function assertConsumerTypechecks(root: string, dts: string, source: string): void {
const declaration = join(root, 'packages/remote/lib/typert.remote-client.d.ts')
mkdirSync(join(root, 'packages/remote/lib'), { recursive: true })
writeFileSync(declaration, dts)
const consumer = join(root, 'consumer.ts')
writeFileSync(consumer, source)
const config = ts.readConfigFile(join(root, 'tsconfig.base.json'), path => ts.sys.readFile(path))
expect(config.error).toBeUndefined()
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
expect(parsed.errors).toEqual([])
const options = { ...parsed.options, composite: false, skipLibCheck: false, paths: {
...parsed.options.paths, '@fixture/remote/remote': [declaration],
} }
const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram([consumer], options))
expect(diagnostics.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([])
}
describe('Remote method metadata', { timeout: 60_000 }, () => {
it('keeps selected and full-face artifacts identical when another package augments inputs and results', async () => {
const root = fixture()
const extension = join(root, 'packages/extension')
mkdirSync(join(extension, 'src'), { recursive: true })
writeFileSync(join(extension, 'package.json'), JSON.stringify({
name: '@fixture/extension', private: true, type: 'module',
exports: {
'.': './src/index.ts',
'./typert': { types: './lib/typert.host.d.ts', default: './lib/typert.host.js' },
},
files: ['lib/typert.host.js', 'lib/typert.host.d.ts'],
}))
writeFileSync(join(extension, 'tsconfig.json'), JSON.stringify({
extends: '../../tsconfig.base.json', include: ['src'], references: [{ path: '../remote' }],
compilerOptions: { rootDir: 'src', outDir: 'lib/types', noEmit: false, declaration: true, emitDeclarationOnly: true },
}))
writeFileSync(join(extension, 'src/index.ts'), `import '@fixture/remote/types'
/** @typert schema */
export interface ExtensionMarker { readonly enabled: boolean }
declare module '@fixture/remote/types' {
interface CreateGoalRequest { readonly extension?: { readonly label: string } }
interface CreateGoalResult { readonly extension?: string }
}
`)
editFile(root, 'tsconfig.host.json', (source) => {
const config = JSON.parse(source) as { references: { path: string }[] }
config.references.push({ path: './packages/extension' })
return JSON.stringify(config)
})
const selected = new WorkspaceTypertGenerator(root).generate(['@fixture/remote'], ['host'])
const complete = new WorkspaceTypertGenerator(root).generate(undefined, ['host'])
expect(selected.map(artifact => artifact.package)).toEqual(['@fixture/remote'])
expect(selected[0]).toEqual(complete.find(artifact => artifact.package === '@fixture/remote'))
const generated = await executable(selected[0]!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: 'ship', extension: { label: 'installed' } } }
for (const strict of [false, true]) {
const view = method.input({ strict })
expect(view.create().parse(input)).toEqual(input)
expect(view.jsonSchema).toMatchObject({ properties: { request: {
properties: { extension: { additionalProperties: !strict } },
} } })
}
const extra = { ...input, request: { ...input.request, extension: { label: 'installed', extra: true } } }
expect(method.input().create().parse(extra)).toEqual(input)
expect(() => method.input({ strict: true }).create().parse(extra)).toThrow()
const output = method.result.create()
expect(output.parse({ ref: 'created', extension: 'installed' })).toEqual({ ref: 'created', extension: 'installed' })
expect(() => output.parse({ ref: 'created', extension: 1 })).toThrow()
})
it('shares direct descriptors and codec documentation in ordinary Host and Remote artifacts', async () => {
const [artifact] = new WorkspaceTypertGenerator(fixture()).generate()
if (artifact?.remote === undefined) throw new Error('missing Remote artifact')
expect(artifact.remote).not.toHaveProperty('source')
const remote = await executable(artifact.remote.js)
const host = await executableRemoteModule<{ TYPERT: { invocations: RuntimeDescriptor[] } }>(artifact.js)
expect(remote).not.toHaveProperty('REMOTE_METHODS')
expect(Object.keys(remote.TYPERT_REMOTE.methods)).toEqual(['goals/create', 'goals/watch'])
expect(remote.TYPERT_REMOTE.descriptors).toHaveLength(3)
const method = remote.TYPERT_REMOTE.methods['goals/create']!
expect(method).toBe(remote.TYPERT_REMOTE.descriptors[0])
for (const descriptors of [host.TYPERT.invocations, remote.TYPERT_REMOTE.descriptors]) {
const current = descriptors.find(candidate => candidate.id.endsWith('/create')) as RuntimeMethod
expect(current).toMatchObject({
id: '@fixture/remote#goals/create', namespace: 'goals', method: 'create',
invocation: { kind: 'direct' },
})
expect(current).not.toHaveProperty('metadata')
expect(current).not.toHaveProperty('descriptor')
expect(current).not.toHaveProperty('output')
expect(current).not.toHaveProperty('parameterDescriptions')
expect(current.mode).toBeUndefined()
expect(current.description).toBe('Create a named goal.')
expect(current.parameters).toMatchObject([
{ name: 'agent', wire: 'agentId', description: 'owning Agent.' },
{ name: 'request', wire: 'request', description: 'goal fields.' },
])
for (const strict of [false, true]) {
const input = current.input({ strict })
expect(input.jsonSchema).toEqual(method.input({ strict }).jsonSchema)
expect(input.jsonSchema).toMatchObject({ properties: {
request: { description: 'goal fields.', additionalProperties: !strict, properties: { title: { description: 'Human-readable goal name.' } } },
} })
expect(input.jsonSchemaError).toBeUndefined()
assertSupportedJsonSchema(input.jsonSchema)
expect(input.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } })
for (const invalid of [
{ request: { title: 'ship' } },
{ agentId: 'agent' },
{ agentId: 'agent', request: { title: 'ship' }, extra: true },
]) expect(() => input.create().parse(invalid)).toThrow()
}
expect(current.input().create().parse({ agentId: 'agent', request: { title: 'ship', extra: true } }))
.toEqual({ agentId: 'agent', request: { title: 'ship' } })
expect(() => current.input({ strict: true }).create().parse({ agentId: 'agent', request: { title: 'ship', extra: true } })).toThrow()
expect(current.parameters[1]!.codec.create().parse({ title: 'ship', extra: true })).toEqual({ title: 'ship' })
expect(current.result.description).toBe('the created reference.')
expect(current.result.jsonSchema).toEqual(method.result.jsonSchema)
expect(current.result.jsonSchema?.description).toBe('the created reference.')
expect(current.result.jsonSchemaError).toBeUndefined()
assertSupportedJsonSchema(current.result.jsonSchema)
expect(current.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' })
expect(() => current.result.create().parse({ ref: 1 })).toThrow()
const create = vi.fn(async () => ({ ok: true, value: { ref: 'created' } }))
const signal = new AbortController().signal
await expect(current.invoke({ goals: { create } }, { agentId: 'agent', request: { title: 'ship' } }, signal))
.resolves.toEqual({ ok: true, value: { ref: 'created' } })
expect(create).toHaveBeenCalledWith('agent', { title: 'ship' }, signal)
}
const scoped = remote.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/rename'))!
for (const descriptor of [scoped, host.TYPERT.invocations.find(candidate => candidate.id === scoped.id)]) {
expect(descriptor).toMatchObject({ invocation: { kind: 'context', context: 'agent' } })
for (const key of ['metadata', 'descriptor', 'input', 'invoke']) expect(descriptor).not.toHaveProperty(key)
}
expect(remote.TYPERT_REMOTE.methods['goals/rename']).toBeUndefined()
expect(remote.TYPERT_REMOTE.methods['agent:goals/rename']).toBeUndefined()
expect(remote.TYPERT_REMOTE.methods['goals/missing']).toBeUndefined()
expect(remote.TYPERT_REMOTE.methods['goals/watch']).toBe(remote.TYPERT_REMOTE.descriptors[2])
expect(artifact.remote.dts).toContain('export declare const TYPERT_REMOTE: TypertRemoteInfo<{')
expect(artifact.remote.dts).not.toContain('REMOTE_METHODS')
expect(artifact.remote.dts).toContain("'agent:goals/rename':")
expect(artifact.remote.dts).not.toContain("'goals/rename':")
})
it('emits one static method spec without runtime Host imports or eager schemas', async () => {
const [artifact] = new WorkspaceTypertGenerator(fixture()).generate()
if (artifact?.remote === undefined) throw new Error('missing Remote artifact')
expect(artifact.remote.js).toContain('export const TYPERT_REMOTE = createTypertRemoteInfo({')
expect(artifact.remote.js.match(/'goals\/create':/g)).toHaveLength(1)
expect(artifact.remote.js).not.toMatch(/\b(?:id|namespace|method|input|invoke|metadata|output):/)
expect(artifact.remote.js).not.toContain("wire: 'request'")
for (const [index, source] of [artifact.js, artifact.remote.js].entries()) {
const tree = ts.createSourceFile('artifact.js', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS)
const imports = tree.statements.filter(ts.isImportDeclaration).map((statement) => {
if (!ts.isStringLiteral(statement.moduleSpecifier)) throw new Error('nonliteral import')
return statement.moduleSpecifier.text
})
expect(imports.sort()).toEqual(['@deepseek-ai/dsh-typert-protocol/remote-info', 'zod'])
const parameterSpecs: ts.ObjectLiteralExpression[] = []
const collectParameters = (node: ts.Node): void => {
if (ts.isPropertyAssignment(node) && node.name.getText(tree) === 'parameters') {
if (!ts.isArrayLiteralExpression(node.initializer)) throw new Error('nonliteral parameters')
for (const parameter of node.initializer.elements) {
if (!ts.isObjectLiteralExpression(parameter)) throw new Error('nonliteral parameter')
parameterSpecs.push(parameter)
}
}
ts.forEachChild(node, collectParameters)
}
collectParameters(tree)
expect(parameterSpecs).toHaveLength(4)
for (const parameter of parameterSpecs) {
const codecs = parameter.properties.filter(ts.isPropertyAssignment)
.filter(property => ['codec', 'strictCodec'].includes(property.name.getText(tree)))
expect(codecs.map(property => property.name.getText(tree))).toEqual(['codec', 'strictCodec'])
for (const codec of codecs) expect(ts.isObjectLiteralExpression(codec.initializer)).toBe(true)
}
const states = [...source.matchAll(/^let ([\w$]+\$value)$/gm)].map(match => match[1]!)
expect(states.length).toBeGreaterThan(0)
const generated = await executableRemoteModule<Generated & {
readonly TYPERT?: { readonly invocations: readonly RuntimeDescriptor[] }
readSchemaStates(): readonly unknown[]
}>(`${source}\nexport const readSchemaStates = () => [${states.join(', ')}]\n`)
expect(generated.readSchemaStates()).toEqual(states.map(() => undefined))
const method = index === 0
? generated.TYPERT!.invocations.find(descriptor => descriptor.id.endsWith('/create')) as RuntimeMethod
: generated.TYPERT_REMOTE.methods['goals/create']!
const first = method.input({ strict: index === 0 })
const second = method.input({ strict: index !== 0 })
expect(generated.readSchemaStates()).toEqual(states.map(() => undefined))
first.create()
const firstStates = generated.readSchemaStates()
expect(firstStates.some(state => state !== undefined)).toBe(true)
second.create()
expect(generated.readSchemaStates().some((state, position) => state !== undefined && firstStates[position] === undefined)).toBe(true)
expect(first.create()).toBe(first.create())
expect(second.create()).toBe(second.create())
}
})
it('generates normal and strict input views from plain Remote methods without changing result codecs', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', `readonly title: string
readonly metadata: { readonly label: string }
readonly items?: readonly { readonly label: string }[]`))
editFile(root, 'packages/remote/src/index.ts', source => source.replace(' constructor() {', ` @Remote
echo(request: CreateGoalRequest): CreateGoalRequest { return request }
constructor() {`))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const normal = method.input()
const strict = method.input({ strict: true })
expect(method.input()).toBe(normal)
expect(method.input({})).toBe(normal)
expect(method.input({ strict: false })).toBe(normal)
expect(method.input({ strict: true })).toBe(strict)
expect(strict).not.toBe(normal)
expect(normal.create()).toBe(normal.create())
expect(strict.create()).toBe(strict.create())
expect(strict.create()).not.toBe(normal.create())
expect(normal.jsonSchema).toBe(normal.jsonSchema)
expect(strict.jsonSchema).toBe(strict.jsonSchema)
expect(strict.jsonSchema).not.toBe(normal.jsonSchema)
for (const [view, additionalProperties] of [[normal, true], [strict, false]] as const) {
assertSupportedJsonSchema(view.jsonSchema)
expect(view.jsonSchema).toMatchObject({ additionalProperties: false, properties: { request: {
additionalProperties,
properties: {
metadata: { additionalProperties },
items: { items: { additionalProperties } },
},
} } })
}
const input = { agentId: 'agent', request: { title: 'ship', metadata: { label: 'app payload' }, items: [{ label: 'first' }] } }
expect(strict.create().parse(input)).toEqual(input)
for (const request of [
{ ...input.request, extra: true },
{ ...input.request, metadata: { ...input.request.metadata, extra: true } },
{ ...input.request, items: [{ label: 'first', extra: true }] },
]) {
const extra = { ...input, request }
expect(validateJsonSchemaValue(normal.jsonSchema!, extra)).toEqual([])
expect(normal.create().parse(extra)).toEqual(input)
expect(validateJsonSchemaValue(strict.jsonSchema!, extra)).not.toEqual([])
expect(() => strict.create().parse(extra)).toThrow()
expect(method.parameters[1]!.codec.create().parse(request)).toEqual(input.request)
}
for (const view of [normal, strict]) {
expect(() => view.create().parse({ ...input, unexpected: true })).toThrow()
expect(validateJsonSchemaValue(view.jsonSchema!, { ...input, unexpected: true })).not.toEqual([])
}
const echo = generated.TYPERT_REMOTE.methods['goals/echo']!
const extraRequest = { ...input.request, metadata: { label: 'app payload', extra: true } }
expect(echo.input().create().parse({ request: extraRequest })).toEqual({ request: input.request })
expect(() => echo.input({ strict: true }).create().parse({ request: extraRequest })).toThrow()
expect(echo.result.create().parse(extraRequest)).toEqual(input.request)
expect(echo.result.jsonSchema).toMatchObject({ additionalProperties: true, properties: { metadata: { additionalProperties: true } } })
const create = vi.fn(async () => ({ ok: true, value: { ref: 'created' } }))
await method.invoke({ goals: { create } }, input)
expect(create).toHaveBeenCalledWith('agent', input.request, undefined)
})
it('generates undocumented methods with parsers and optional source documentation', async () => {
const [artifact] = new WorkspaceTypertGenerator(resolve(import.meta.dirname, 'fixtures/remote-model')).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
expect(method.description).toBeUndefined()
expect(method.parameters.map(parameter => parameter.description)).toEqual([undefined, undefined])
expect(method.result.description).toBeUndefined()
for (const view of [method.input(), method.input({ strict: true })]) {
expect(view.jsonSchemaError).toBeUndefined()
expect(view.create().parse({ agentId: 'agent', request: { title: 'ship' } })).toEqual({ agentId: 'agent', request: { title: 'ship' } })
}
expect(method.result.jsonSchemaError).toBeUndefined()
expect(method.result.create().parse({ ref: 'created' })).toEqual({ ref: 'created' })
})
it('projects every emitted schema into the enforced tool-schema subset', async () => {
const [artifact] = new WorkspaceTypertGenerator(fixture()).generate()
const remote = await executable(artifact!.remote!.js)
const host = await executableRemoteModule<{ readonly TYPERT: { readonly invocations: readonly RuntimeDescriptor[] } }>(artifact!.js)
let projected = 0
let reported = 0
const verify = (value: RuntimeValue): void => {
if (value.jsonSchema === undefined) {
// An unrepresentable type must say so instead of publishing a weaker schema.
expect(value.jsonSchemaError).toContain('typert tool schema:')
reported += 1
return
}
assertSupportedJsonSchema(value.jsonSchema)
projected += 1
}
for (const method of Object.values(remote.TYPERT_REMOTE.methods)) {
verify(method.input())
verify(method.input({ strict: true }))
verify(method.result)
}
for (const descriptor of host.TYPERT.invocations) {
for (const parameter of descriptor.parameters) verify(parameter.codec)
verify(descriptor.result)
}
const expected = Object.keys(remote.TYPERT_REMOTE.methods).length * 3
+ host.TYPERT.invocations.reduce((total, descriptor) => total + descriptor.parameters.length + 1, 0)
expect(projected + reported).toBe(expected)
expect(projected).toBeGreaterThan(0)
})
it('typechecks ordinary declarations for named invocation, Context exclusion, uplink, and decoded bytes', () => {
const root = fixture()
editFile(root, 'packages/remote/src/index.ts', source => `import type { RemoteStream } from '@deepseek-ai/dsh-typert-protocol'\n${source.replace(' constructor() {', ` @Remote
bytes(): Uint8Array { return new Uint8Array([255]) }
@Remote({ mode: 'stream' })
async *attach(): RemoteStream<CreateGoalResult, CreateGoalRequest> { yield { ref: 'first' } }
constructor() {`)}`)
const [artifact] = new WorkspaceTypertGenerator(root).generate()
assertConsumerTypechecks(root, artifact!.remote!.dts, `import { TYPERT_REMOTE } from '@fixture/remote/remote'
import type { InvocationDescriptor, TypertClientRemote, TypertRemoteInfo, TypertRemoteInputOptions, TypertRemoteMethod, TypertRemoteValue, RemoteResult, RemoteStreamHandle, TypertRemoteScopeMap } from '@deepseek-ai/dsh-typert-protocol'
import type { CreateGoalRequest, CreateGoalResult } from '@fixture/remote/types'
declare const remote: TypertClientRemote
const info: TypertRemoteInfo = TYPERT_REMOTE
const method = TYPERT_REMOTE.methods['goals/create']
const typedMethod: TypertRemoteMethod<{ agentId: string; request: CreateGoalRequest }, CreateGoalResult> = method
const descriptor: InvocationDescriptor = method
const descriptors: readonly InvocationDescriptor[] = TYPERT_REMOTE.descriptors
const unaryMode: undefined = method.mode
const streamMode: 'stream' = TYPERT_REMOTE.methods['goals/attach'].mode
const direct: 'direct' = method.invocation.kind
const id: string = method.id
const codecMode: 'strict' = method.result.mode
const typeSymbol: string = method.result.typeSymbol
const keys: Record<keyof typeof TYPERT_REMOTE.methods, true> = {
'goals/create': true, 'goals/watch': true, 'goals/bytes': true, 'goals/attach': true,
}
const parsed: { readonly agentId: string; readonly request: CreateGoalRequest } = method.input().create().parse({})
const options: TypertRemoteInputOptions = { strict: true }
const strictInput: TypertRemoteValue<typeof parsed> = method.input(options)
const strictParsed: typeof parsed = strictInput.create().parse({})
const normalInput: TypertRemoteValue<typeof parsed> = method.input({ strict: false })
// @ts-expect-error Input options are readonly.
options.strict = false
// @ts-expect-error Strict selection accepts a boolean, not a string.
method.input({ strict: 'true' })
// @ts-expect-error Input parsing requires selecting a view.
method.input.create()
// @ts-expect-error Strict input parsers retain the request type.
const wrongInput: { readonly request: string } = strictInput.create().parse({})
const output: CreateGoalResult = method.result.create().parse({})
const value: Promise<RemoteResult<CreateGoalResult>> = method.invoke(remote, parsed, new AbortController().signal)
// @ts-expect-error Invocation results retain the owner's result fields.
const wrongResult: Promise<RemoteResult<string>> = method.invoke(remote, parsed)
// @ts-expect-error Output parsers retain the owner's result fields.
const wrongOutput: string = method.result.create().parse({})
const description: string | undefined = method.description
const schema: Readonly<Record<string, unknown>> | undefined = method.input().jsonSchema
const projectionError: string | undefined = method.input().jsonSchemaError
const parameterDescription: string | undefined = method.parameters[0]?.description
const resultDescription: string | undefined = method.result.description
// @ts-expect-error The request retains the owner's title type.
method.invoke(remote, { agentId: 'agent', request: { title: 1 } })
// @ts-expect-error Required named arguments cannot be omitted.
method.invoke(remote, { agentId: 'agent' })
// @ts-expect-error A Context receiver needs scoped invocation.
TYPERT_REMOTE.methods['goals/rename']
// @ts-expect-error Scoped keys are absent from the direct-method index.
TYPERT_REMOTE.methods['agent:goals/rename']
// @ts-expect-error Unknown keys are absent from the exact method index.
TYPERT_REMOTE.methods['goals/missing']
declare const rename: TypertRemoteScopeMap['agent:goals/rename']
rename({ ref: 'goal', title: 'ship' })
// @ts-expect-error The method itself is the descriptor.
method.descriptor
// @ts-expect-error Descriptors have no separate method metadata.
method.metadata
// @ts-expect-error Results use the descriptor codec, not an output wrapper.
method.output
// @ts-expect-error Parameter documentation lives on each parameter.
method.parameterDescriptions
const stream = TYPERT_REMOTE.methods['goals/attach'].invoke(remote, {})
const typedStream: RemoteStreamHandle<CreateGoalResult, CreateGoalRequest> = stream
stream.send({ title: 'ship' })
// @ts-expect-error Uplink items retain their declared type.
stream.send({ title: 1 })
stream.end()
stream.dispose()
const watch = TYPERT_REMOTE.methods['goals/watch'].invoke(remote, { agentId: 'agent' })
const typedWatch: RemoteStreamHandle<CreateGoalResult, never> = watch
// @ts-expect-error A downlink-only method accepts no uplink items.
watch.send({ title: 'ship' })
watch.end()
watch.dispose()
async function bytes() {
const parsed = TYPERT_REMOTE.methods['goals/bytes'].result.create().parse(new Uint8Array(new SharedArrayBuffer(1)))
const native: Uint8Array<ArrayBufferLike> = parsed
// @ts-expect-error Parsing does not reconstruct carrier-owned ArrayBuffer backing.
const narrowed: Uint8Array<ArrayBuffer> = parsed
void [native, narrowed]
const result = await TYPERT_REMOTE.methods['goals/bytes'].invoke(remote, {})
if (!result.ok) return
const data: Uint8Array<ArrayBuffer> = result.value
new Blob([data])
// @ts-expect-error Decoded bytes have ordinary ArrayBuffer backing.
const shared: Uint8Array<SharedArrayBuffer> = result.value
// @ts-expect-error Binary results are not base64 strings.
const base64: string = result.value
void shared
void base64
}
void [info, typedMethod, descriptor, descriptors, unaryMode, streamMode, direct, id, codecMode, typeSymbol, keys]
void [strictParsed, normalInput, wrongInput, output, value, wrongResult, wrongOutput, description, schema, projectionError, parameterDescription, resultDescription, typedStream, typedWatch, bytes]
`)
})
it('keeps overlapping-union input parsers and source docs when JSON Schema projection is unsupported', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string', 'readonly title: { first: string } | { second: string }'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
expect(method.description).toBe('Create a named goal.')
expect(method.parameters.find(parameter => parameter.wire === 'request')?.description).toBe('goal fields.')
for (const view of [method.input(), method.input({ strict: true })]) {
expect(view.jsonSchema).toBeUndefined()
expect(view.jsonSchemaError).toContain('selected union branches overlap')
for (const title of [{ first: 'a' }, { second: 'b' }]) {
const input = { agentId: 'agent', request: { title } }
expect(view.create().parse(input)).toEqual(input)
expect(method.parameters[1]!.codec.create().parse(input.request)).toEqual(input.request)
}
expect(() => view.create().parse({ agentId: 'agent', request: { title: {} } })).toThrow()
}
})
it('keeps recursive input parsers independent when JSON Schema cannot represent them', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: string\n readonly next?: CreateGoalRequest'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: 'first', next: { title: 'second' } } }
for (const view of [method.input(), method.input({ strict: true })]) {
expect(view.jsonSchema).toBeUndefined()
expect(view.jsonSchemaError).toContain('recursive selected type')
expect(view.create().parse(input)).toEqual(input)
expect(() => view.create().parse({ agentId: 'agent', request: { title: 'first', next: { title: 2 } } })).toThrow()
}
const extra = { agentId: 'agent', request: { title: 'first', next: { title: 'second', extra: true } } }
expect(method.input().create().parse(extra)).toEqual(input)
expect(() => method.input({ strict: true }).create().parse(extra)).toThrow()
expect(method.parameters[1]!.codec.create().parse(extra.request)).toEqual(input.request)
})
it('supplies Zod to parameterless Host and Remote artifacts without exported schemas', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/index.ts', () => `import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
export class GoalService extends TypertRemoteService {
constructor() { super(undefined, 'goals') }
@Remote
ready(): boolean { return true }
}
`)
const [artifact] = new WorkspaceTypertGenerator(root).generate()
if (artifact?.remote === undefined) throw new Error('missing Remote artifact')
for (const source of [artifact.js, artifact.remote.js]) {
expect(source).toContain("import { z } from 'zod'")
expect(source).toContain('}, z)')
}
const host = await executableRemoteModule<{ TYPERT: { schemas: unknown[]; invocations: RuntimeMethod[] } }>(artifact.js)
const remote = await executable(artifact.remote.js)
expect(host.TYPERT.schemas).toEqual([])
expect(host.TYPERT.invocations).toHaveLength(1)
for (const method of [host.TYPERT.invocations[0]!, remote.TYPERT_REMOTE.methods['goals/ready']!]) {
for (const strict of [false, true]) {
const input = method.input({ strict })
const parser = input.create()
expect(input.create()).toBe(parser)
expect(parser.parse({})).toEqual({})
expect(() => parser.parse({ extra: true })).toThrow()
}
expect(method.result.create().parse(true)).toBe(true)
}
})
it('supports optional named arguments and parameterless methods', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/index.ts', source => source.replace(' constructor() {', ` @Remote
ready(): boolean { return true }
@Remote
labelled(id: string, label?: string): string { return label ?? id }
constructor() {`))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const ready = generated.TYPERT_REMOTE.methods['goals/ready']!
for (const view of [ready.input(), ready.input({ strict: true })]) {
expect(view.jsonSchema).toEqual({ type: 'object', properties: {}, required: [], additionalProperties: false })
expect(view.create().parse({})).toEqual({})
expect(() => view.create().parse({ extra: true })).toThrow()
}
const callReady = vi.fn(async () => ({ ok: true, value: true }))
await expect(ready.invoke({ goals: { ready: callReady } }, {})).resolves.toEqual({ ok: true, value: true })
expect(callReady).toHaveBeenCalledWith()
const labelled = generated.TYPERT_REMOTE.methods['goals/labelled']!
for (const view of [labelled.input(), labelled.input({ strict: true })]) {
expect(view.jsonSchema).toMatchObject({ required: ['id'], properties: { label: { type: 'string' } } })
for (const input of [{ id: 'goal' }, { id: 'goal', label: 'ship' }]) {
expect(validateJsonSchemaValue(view.jsonSchema!, input)).toEqual([])
expect(view.create().parse(input)).toEqual(input)
}
expect(() => view.create().parse({ label: 'ship' })).toThrow()
expect(() => view.create().parse({ id: 'goal', label: 1 })).toThrow()
}
const callLabelled = vi.fn(async () => ({ ok: true, value: 'goal' }))
await labelled.invoke({ goals: { labelled: callLabelled } }, { id: 'goal' })
expect(callLabelled).toHaveBeenCalledWith('goal', undefined)
assertConsumerTypechecks(root, artifact!.remote!.dts, `import { TYPERT_REMOTE } from '@fixture/remote/remote'
import type { TypertClientRemote, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
declare const remote: TypertClientRemote
const ready: Promise<RemoteResult<boolean>> = TYPERT_REMOTE.methods['goals/ready'].invoke(remote, {})
TYPERT_REMOTE.methods['goals/labelled'].invoke(remote, { id: 'goal' })
TYPERT_REMOTE.methods['goals/labelled'].invoke(remote, { id: 'goal', label: 'ship' })
// @ts-expect-error Optional labels still require strings when present.
TYPERT_REMOTE.methods['goals/labelled'].invoke(remote, { id: 'goal', label: 1 })
void ready
`)
})
it('preserves discriminator unions and nested unknown-field rejection in tool schemas', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
"readonly title: { readonly kind: 'text'; readonly text: string } | { readonly kind: 'count'; readonly count: number }"))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const strict = method.input({ strict: true })
const schema = strict.jsonSchema
assertSupportedJsonSchema(schema)
for (const title of [{ kind: 'text', text: 'ship' }, { kind: 'count', count: 2 }]) {
const input = { agentId: 'agent', request: { title } }
expect(validateJsonSchemaValue(schema, input)).toEqual([])
expect(strict.create().parse(input)).toEqual(input)
}
const extra = { agentId: 'agent', request: { title: { kind: 'text', text: 'ship', count: 2 } } }
expect(validateJsonSchemaValue(schema, extra)).not.toEqual([])
expect(() => strict.create().parse(extra)).toThrow()
expect(validateJsonSchemaValue(method.input().jsonSchema!, extra)).toEqual([])
expect(method.input().create().parse(extra)).toEqual({ agentId: 'agent', request: { title: { kind: 'text', text: 'ship' } } })
})
it('returns the ordinary stream handle without wrapping its uplink or disposal', async () => {
const [artifact] = new WorkspaceTypertGenerator(fixture()).generate()
const generated = await executable(artifact!.remote!.js)
const handle = Object.assign((async function* () { yield { ref: 'first' } })(), {
send: vi.fn(), end: vi.fn(), dispose: vi.fn(),
})
const watch = vi.fn(() => handle)
const method = generated.TYPERT_REMOTE.methods['goals/watch']!
expect(method.mode).toBe('stream')
const signal = new AbortController().signal
expect(method.invoke({ goals: { watch } }, { agentId: 'agent' }, signal)).toBe(handle)
expect(watch).toHaveBeenCalledWith('agent', signal)
const items = []
for await (const item of handle) items.push(item)
expect(items).toEqual([{ ref: 'first' }])
})
it('projects canonical JsonValue and JSON dictionaries without recursive schema references', async () => {
const root = fixture()
const directory = join(root, 'packages/json-values-fixture')
mkdirSync(join(directory, 'src'), { recursive: true })
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-util-values', exports: { '.': './src/index.ts' } }))
writeFileSync(join(directory, 'tsconfig.json'), readFileSync(join(root, 'packages/domain/tsconfig.json'), 'utf8'))
writeFileSync(join(directory, 'src/index.ts'), 'export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n')
editFile(root, 'tsconfig.host.json', (source) => {
const config = JSON.parse(source) as { references: { path: string }[] }
config.references.push({ path: './packages/json-values-fixture' })
return JSON.stringify(config)
})
editFile(root, 'tsconfig.base.json', (source) => {
const config = JSON.parse(source) as { compilerOptions: { paths: Record<string, string[]> } }
config.compilerOptions.paths['@deepseek-ai/dsh-util-values'] = ['./packages/json-values-fixture/src/index.ts']
return JSON.stringify(config)
})
editFile(root, 'packages/remote/src/types.ts', source => "import type { JsonValue } from '@deepseek-ai/dsh-util-values'\n" + source
.replace('readonly title: string', 'readonly title: string\n readonly payload: JsonValue\n readonly labels?: Record<string, JsonValue>'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: 'ship', payload: [null, { nested: [true, 3] }], labels: { tag: ['a', 'b'], extra: { nested: true } } } }
for (const strict of [false, true]) {
const view = method.input({ strict })
const schema = view.jsonSchema
assertSupportedJsonSchema(schema)
expect(JSON.stringify(schema)).not.toContain('$ref')
expect(schema).toMatchObject({ properties: { request: {
additionalProperties: !strict,
properties: { payload: {}, labels: { type: 'object', additionalProperties: true } },
} } })
expect(validateJsonSchemaValue(schema, input)).toEqual([])
expect(view.create().parse(input)).toEqual(input)
expect(() => view.create().parse({ agentId: 'agent', request: { title: 'ship', payload: undefined } })).toThrow()
}
})
it('keeps typed dictionary keys open when input JSON Schema is unsupported', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: string\n readonly labels: Record<string, string>'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: 'ship', labels: { first: 'a', extra: 'b' } } }
for (const view of [method.input(), method.input({ strict: true })]) {
expect(view.jsonSchema).toBeUndefined()
expect(view.jsonSchemaError).toContain('selected dictionaries require string keys and unconstrained JSON values')
expect(view.create().parse(input)).toEqual(input)
expect(() => view.create().parse({ ...input, request: { title: 'ship', labels: { first: 1 } } })).toThrow()
}
const extra = { ...input, request: { ...input.request, extra: true } }
expect(method.input().create().parse(extra)).toEqual(input)
expect(() => method.input({ strict: true }).create().parse(extra)).toThrow()
})
it('retains tuple input parsers under both policies without requiring a tool projection', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: readonly [{ readonly first: string }, { readonly second: string }]'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: [{ first: 'one' }, { second: 'two' }] } }
for (const strict of [false, true]) {
const view = method.input({ strict })
expect(view.create().parse(input)).toEqual(input)
expect(view.jsonSchema).toBeUndefined()
expect(view.jsonSchemaError).toContain('tuple')
}
const extra = { agentId: 'agent', request: { title: [{ first: 'one', extra: true }, { second: 'two' }] } }
expect(method.input().create().parse(extra)).toEqual(input)
expect(() => method.input({ strict: true }).create().parse(extra)).toThrow()
})
it('keeps declared dictionary properties more specific than the index value', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: string\n readonly payload: { readonly fixed: { readonly a: string; readonly b: string }; readonly [key: string]: { readonly a: string } }'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed: { a: 'a', b: 'b' }, extra: { a: 'extra' } } } }
for (const strict of [false, true]) {
const view = method.input({ strict })
expect(view.create().parse(input)).toEqual(input)
expect(view.jsonSchema).toBeUndefined()
expect(view.jsonSchemaError).toContain('selected dictionaries')
}
const withExtraValue = { ...input, request: { ...input.request, payload: { ...input.request.payload, extra: { a: 'extra', b: 'not declared' } } } }
expect(method.input().create().parse(withExtraValue)).toEqual(input)
expect(() => method.input({ strict: true }).create().parse(withExtraValue)).toThrow()
})
it('enforces index values on named fields without rejecting their merged nested fields', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: string\n readonly payload: Record<string, { readonly base: string }> & { readonly fixed: { readonly own: string } }'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const valid = { agentId: 'agent', request: { title: 'ship', payload: {
fixed: { base: 'common', own: 'specific' }, extra: { base: 'value' },
} } }
const strict = method.input({ strict: true })
expect(strict.jsonSchema).toBeUndefined()
expect(strict.jsonSchemaError).toContain('selected dictionaries')
for (const view of [method.input(), strict]) {
expect(view.create().parse(valid)).toEqual(valid)
expect(() => view.create().parse({ ...valid, request: { ...valid.request, payload: {
...valid.request.payload, fixed: { own: 'specific' },
} } })).toThrow()
}
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
...valid.request.payload, fixed: { base: 'common' },
} } })).toThrow()
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
...valid.request.payload, fixed: { ...valid.request.payload.fixed, unexpected: true },
} } })).toThrow()
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
...valid.request.payload, extra: { base: 'value', unexpected: true },
} } })).toThrow()
expect(() => strict.create().parse({ ...valid, request: { ...valid.request, payload: {
...valid.request.payload, extra: { own: 'missing base' },
} } })).toThrow()
const outer = { ...valid, request: { ...valid.request, payload: { ...valid.request.payload, extraOuter: true } } }
expect(() => strict.create().parse(outer)).toThrow()
})
it('merges overlapping indexed fields before closing their nested objects', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: string\n readonly payload: Record<string, { readonly base: string; readonly nested: { readonly left: string } }> & { readonly fixed: { readonly own: string; readonly nested: { readonly right: string } } }'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const strict = generated.TYPERT_REMOTE.methods['goals/create']!.input({ strict: true })
const fixed = { base: 'common', own: 'specific', nested: { left: 'index', right: 'declared' } }
const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed } } }
expect(strict.create().parse(input)).toEqual(input)
for (const nested of [{ right: 'declared' }, { left: 'index' }, { ...fixed.nested, extra: true }]) {
expect(() => strict.create().parse({ ...input, request: { ...input.request, payload: {
fixed: { ...fixed, nested },
} } })).toThrow()
}
})
it('keeps a shared indexed value type valid for a named field', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => `${source}\ntype Entry = { readonly base: string }\n`)
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
'readonly title: string\n readonly payload: Record<string, Entry> & { readonly fixed: Entry }'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const strict = generated.TYPERT_REMOTE.methods['goals/create']!.input({ strict: true })
const input = { agentId: 'agent', request: { title: 'ship', payload: { fixed: { base: 'common' } } } }
expect(strict.create().parse(input)).toEqual(input)
expect(() => strict.create().parse({ ...input, request: { ...input.request, payload: {
fixed: { base: 'common', extra: true },
} } })).toThrow()
})
it.each([
{
name: 'a union of named object fields',
type: 'Record<string, { base: string }> & { fixed: { own: string } | { other: number } }',
valid: [{ base: 'base', own: 'own' }, { base: 'base', other: 1 }],
invalid: [{ own: 'own' }, { other: 1 }, { base: 'base' }, null],
optional: false,
},
{
name: 'an optional named field',
type: 'Record<string, { base: string }> & { fixed?: { own: string } }',
valid: [{ base: 'base', own: 'own' }],
invalid: [{ own: 'own' }, { base: 'base' }, null],
optional: true,
},
{
name: 'a nullable index value',
type: 'Record<string, { base: string } | null> & { fixed: { own: string } }',
valid: [{ base: 'base', own: 'own' }],
invalid: [{ own: 'own' }, { base: 'base' }, null],
optional: false,
},
{
name: 'unions on both the named field and index value',
type: 'Record<string, { base: string } | null> & { fixed: { own: string } | null }',
valid: [{ base: 'base', own: 'own' }, null],
invalid: [{ own: 'own' }, { base: 'base' }],
optional: false,
},
])('combines indexed constraints for $name before closing objects', async ({ type, valid, invalid, optional }) => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace('readonly title: string',
`readonly title: string\n readonly payload: ${type}`))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = (payload: object) => ({ agentId: 'agent', request: { title: 'ship', payload } })
for (const view of [method.input(), method.input({ strict: true })]) {
expect(view.jsonSchema).toBeUndefined()
expect(view.jsonSchemaError).toMatch(/selected dictionaries|selected union branches/)
for (const fixed of valid) expect(view.create().parse(input({ fixed }))).toEqual(input({ fixed }))
for (const fixed of invalid) expect(() => view.create().parse(input({ fixed }))).toThrow()
if (optional) expect(view.create().parse(input({}))).toEqual(input({}))
else expect(() => view.create().parse(input({}))).toThrow()
}
for (const fixed of valid) {
if (fixed === null) continue
const extra = input({ fixed: { ...fixed, unexpected: true } })
expect(method.input().create().parse(extra)).toEqual(input({ fixed }))
expect(() => method.input({ strict: true }).create().parse(extra)).toThrow()
}
})
it('keeps normal and closed recursive helper identifiers distinct', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport interface Branch { readonly value: string; readonly next?: Branch }\nexport interface closed$Branch { readonly value: string; readonly next?: closed$Branch }\n`)
editFile(root, 'packages/remote/src/index.ts', source => `import type { Branch, closed$Branch } from './types.ts'\n${source.replace(' constructor() {', ` @Remote
recursive(request: Branch): closed$Branch { return request }
constructor() {`)}`)
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const input = { request: { value: 'first', next: { value: 'second' } } }
for (const source of [artifact!.js, artifact!.remote!.js]) {
await executableRemoteModule(source)
const variables = ts.createSourceFile('artifact.js', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS).statements
.filter(ts.isVariableStatement)
.flatMap(statement => statement.declarationList.declarations.map(declaration => declaration.name.getText()))
expect(new Set(variables).size).toBe(variables.length)
}
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/recursive']!
expect(method.input().create().parse(input)).toEqual(input)
expect(method.input({ strict: true }).create().parse(input)).toEqual(input)
expect(() => method.input({ strict: true }).create().parse({ request: { value: 'first', next: { value: 'second', extra: true } } })).toThrow()
expect(method.result.create().parse(input.request)).toEqual(input.request)
})
it('projects real nominal primitive brands without treating the marker as an object', async () => {
const root = fixture()
const real = resolve(import.meta.dirname, '../../../util/brand')
const destination = join(root, 'packages/util/brand')
mkdirSync(join(destination, 'src'), { recursive: true })
for (const file of ['package.json', 'tsconfig.json', 'src/index.ts']) {
writeFileSync(join(destination, file), readFileSync(join(real, file)))
}
editFile(root, 'tsconfig.host.json', (source) => {
const config = JSON.parse(source) as { references: { path: string }[] }
config.references.push({ path: './packages/util/brand' })
return JSON.stringify(config)
})
editFile(root, 'tsconfig.base.json', (source) => {
const config = JSON.parse(source) as { compilerOptions: { paths: Record<string, string[]> } }
config.compilerOptions.paths['@deepseek-ai/dsh-brand'] = ['./packages/util/brand/src/index.ts']
return JSON.stringify(config)
})
editFile(root, 'packages/domain/src/types.ts', () => `import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable nominal Agent identity. */
export type AgentId = Branded<'AgentId'>
`)
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
for (const view of [method.input(), method.input({ strict: true })]) {
expect(view.jsonSchema).toMatchObject({ properties: { agentId: { type: 'string' } } })
expect(view.create().parse({ agentId: 'agent', request: { title: 'ship' } }))
.toEqual({ agentId: 'agent', request: { title: 'ship' } })
}
expect(artifact!.remote!.dts).toContain("readonly 'agentId': AgentId")
})
it('closes merged object intersections once, including nested request objects', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace(/export interface CreateGoalRequest \{[\s\S]*?\n\}/,
`export type CreateGoalRequest = {
readonly title: string
readonly nested: { readonly alpha: string } & { readonly beta: number }
} & { readonly priority: number }`))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const method = generated.TYPERT_REMOTE.methods['goals/create']!
const input = { agentId: 'agent', request: { title: 'ship', nested: { alpha: 'a', beta: 2 }, priority: 3 } }
const strict = method.input({ strict: true })
const schema = strict.jsonSchema
assertSupportedJsonSchema(schema)
expect(validateJsonSchemaValue(schema, input)).toEqual([])
expect(strict.create().parse(input)).toEqual(input)
const extra = { ...input, request: { ...input.request, nested: { ...input.request.nested, extra: true } } }
expect(validateJsonSchemaValue(schema, extra)).not.toEqual([])
expect(() => strict.create().parse(extra)).toThrow()
expect(validateJsonSchemaValue(method.input().jsonSchema!, extra)).toEqual([])
expect(method.input().create().parse(extra)).toEqual(input)
})
it('retains output codecs and source docs when optional JSON Schema projection is unsupported', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/index.ts', source => source.replace('Promise<CreateGoalResult>', 'Promise<readonly [string, number]>')
.replace('return { ref: `${agent.id}:${request.title}` }', 'return [agent.id, request.title.length]'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const output = generated.TYPERT_REMOTE.methods['goals/create']!.result
expect(output.description).toBe('the created reference.')
expect(output.jsonSchema).toBeUndefined()
expect(output.jsonSchemaError).toContain('unsupported selected tuple type')
expect(output.create().parse(['goal', 2])).toEqual(['goal', 2])
expect(() => output.create().parse(['goal', 'not-number'])).toThrow()
})
it('keeps recursive codec references valid when removing a nominal object marker', async () => {
const root = fixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace(/export interface CreateGoalResult \{[\s\S]*?\n\}/, `declare const nodeBrand: unique symbol
export type CreateGoalResult = { readonly ref: string; readonly next?: CreateGoalResult } & { readonly [nodeBrand]: 'node' }`))
editFile(root, 'packages/remote/src/index.ts', source => source
.replace('return { ref: `${agent.id}:${request.title}` }', 'return { ref: `${agent.id}:${request.title}` } as CreateGoalResult')
.replace('yield { ref: agent.id }', 'yield { ref: agent.id } as CreateGoalResult'))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await executable(artifact!.remote!.js)
const output = generated.TYPERT_REMOTE.methods['goals/create']!.result
expect(output.jsonSchema).toBeUndefined()
expect(output.jsonSchemaError).toContain('recursive selected type')
expect(output.create().parse({ ref: 'first', next: { ref: 'second' } }))
.toEqual({ ref: 'first', next: { ref: 'second' } })
expect(() => output.create().parse({ ref: 'first', next: { ref: 2 } })).toThrow()
})
})
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { WorkspaceAnalyzer } from '../src/analyzer.ts'
import type { InvocationModel } from '../src/model.ts'
import { WorkspaceTypertGenerator } from '../src/workspace.ts'
import { executableRemoteModule } from './remote-module.ts'
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model')
const temporaryRoots: string[] = []
@@ -32,6 +33,8 @@ interface RuntimeDescriptor {
}
readonly result: {
readonly create: () => RuntimeSchema
readonly jsonSchema?: Record<string, unknown>
readonly jsonSchemaError?: string
readonly decode?: (value: unknown) => unknown
readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown
}
@@ -41,6 +44,7 @@ interface RuntimeRemoteModule {
readonly TYPERT_REMOTE: {
readonly package: string
readonly descriptors: readonly RuntimeDescriptor[]
readonly methods: Readonly<Record<string, RuntimeDescriptor>>
}
}
@@ -55,7 +59,7 @@ afterEach(() => {
})
describe('Remote model generation', { timeout: 60_000 }, () => {
it('projects a generic binary result without copying or freezing its bytes', async () => {
it('projects ordinary unary binary results and metadata without copying or freezing bytes', async () => {
const root = copyFixture()
editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport type BinaryFile<Data extends Uint8Array = Uint8Array> = {\n readonly data: Data\n} & { readonly size: number }\n`)
editFile(root, 'packages/remote/src/index.ts', source => `import type { BinaryFile } from './types.ts'\n${source.replace(
@@ -65,12 +69,13 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
const [artifact] = new WorkspaceTypertGenerator(root).generate()
expect(artifact?.js).not.toContain('binaryResult')
expect(artifact?.remote?.dts).toContain('Promise<RemoteResult<RemoteDecoded<BinaryFile>>>')
const generated = await import(`data:text/javascript,${encodeURIComponent(
(artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`),
)}`) as RuntimeRemoteModule
const generated = await executableRemoteModule<RuntimeRemoteModule>(artifact?.remote?.js ?? '')
const descriptor = generated.TYPERT_REMOTE.descriptors.find(invocation => invocation.id.endsWith('/bytes'))
expect(descriptor?.result.decode).toBeTypeOf('function')
if (descriptor === undefined) throw new Error('binary descriptor missing')
expect(generated.TYPERT_REMOTE.methods['goals/bytes']).toBe(descriptor)
expect(descriptor.result.jsonSchema).toBeUndefined()
expect(descriptor.result.jsonSchemaError).toContain('typert tool schema:')
const schema = descriptor.result.create()
for (const data of [new Uint8Array(), new Uint8Array([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) {
const parsed = schema.safeParse({ data, size: data.length })
@@ -163,9 +168,7 @@ export interface BinaryTree {
}\n\nexport type {`,
)}`)
const [artifact] = new WorkspaceTypertGenerator(root).generate()
const generated = await import(`data:text/javascript,${encodeURIComponent(
(artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`),
)}`) as RuntimeRemoteModule
const generated = await executableRemoteModule<RuntimeRemoteModule>(artifact?.remote?.js ?? '')
const codec = (method: string) => {
const descriptor = generated.TYPERT_REMOTE.descriptors.find(item => item.id.endsWith(`/${method}`))
if (descriptor === undefined) throw new Error(`descriptor missing: ${method}`)
@@ -364,7 +367,7 @@ void binaryTree
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
})
expect(artifact?.js).toContain('invocations: [')
expect(artifact?.js).toContain('get invocations() { return $remote.descriptors }')
expect(artifact?.remote?.dts).toContain(
"'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise<RemoteResult<CreateGoalResult>>",
)
@@ -382,8 +385,7 @@ void binaryTree
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
const generated = await executableRemoteModule<RuntimeRemoteModule>(remoteJs)
expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
const create = generated.TYPERT_REMOTE.descriptors[0]
expect(create?.cancellation).toEqual({ parameter: 'signal' })
@@ -438,8 +440,7 @@ export type {`,
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
const generated = await executableRemoteModule<RuntimeRemoteModule>(remoteJs)
const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe'))
const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear'))
expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true)
@@ -504,7 +505,7 @@ export type {`,
const [artifact] = new WorkspaceTypertGenerator(root).generate()
expect(artifact?.remote?.dts).toContain(
" RemoteStreamHandle,\n TypertRemoteContribution,\n} from '@deepseek-ai/dsh-typert-protocol'",
" RemoteStreamHandle,\n TypertRemoteInfo,\n TypertRemoteMethod,\n} from '@deepseek-ai/dsh-typert-protocol'",
)
expect(artifact?.remote?.dts).toContain("declare module '@deepseek-ai/dsh-typert-protocol' {")
expect(artifact?.remote?.dts).toContain(
@@ -518,8 +519,7 @@ export type {`,
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('RemoteStream fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
const generated = await executableRemoteModule<RuntimeRemoteModule>(remoteJs)
const descriptor = generated.TYPERT_REMOTE.descriptors.find(candidate => candidate.id.endsWith('/attach'))
expect(descriptor?.mode).toBe('stream')
expect(descriptor?.uplink?.codec.create().safeParse({ title: 'ship' }).success).toBe(true)
@@ -540,7 +540,7 @@ export type {`,
name: 'an unknown Remote mode',
mode: 'duplex',
method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable<CreateGoalResult>',
message: 'Remote\\(\\) options must contain exactly mode: "stream"',
message: 'Remote\\(\\) options require name or mode: "stream"',
},
])('rejects $name', ({ mode, method, message }) => {
const root = copyFixture()
@@ -613,8 +613,7 @@ export type GenericResult = {
)
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
const generated = await executableRemoteModule<RuntimeRemoteModule>(remoteJs)
const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch'))
const schema = dispatch?.parameters[0]?.codec.create()
expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true)
@@ -757,6 +756,14 @@ export interface ClientMarker {
),
message: 'Gateway service key must be a string literal',
},
{
name: 'an unsupported Gateway binding option',
edit: (source: string) => source.replace(
" super(undefined, 'goals')",
" super(undefined, 'goals', { extra: true })",
),
message: 'only supports a namespace option',
},
{
name: 'TypertRemoteService without a constructor',
edit: (source: string) => source.replace(
@@ -0,0 +1,22 @@
/** Source-only evaluator for generated Remote and Host invocation artifacts. */
import { readFileSync } from 'node:fs'
import ts from 'typescript'
/**
* Load generated JavaScript with the current protocol factory, not built workspace libraries.
* @param source - generated JavaScript containing only Zod and pure Remote-info imports.
* @returns the evaluated generated module.
*/
export async function executableRemoteModule<Module>(source: string): Promise<Module> {
const zodImport = `from ${JSON.stringify(import.meta.resolve('zod'))}`
const factory = ts.transpileModule(
readFileSync(new URL('../../protocol/src/remote-info.ts', import.meta.url), 'utf8'),
{ compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } },
).outputText
const factoryUrl = `data:text/javascript,${encodeURIComponent(factory)}`
const executable = source
.replace(/from (['"])zod\1/g, zodImport)
.replace(/from (['"])@deepseek-ai\/dsh-typert-protocol\/remote-info\1/g, `from ${JSON.stringify(factoryUrl)}`)
return await import(`data:text/javascript,${encodeURIComponent(executable)}`) as Module
}
+6 -6
View File
@@ -21,8 +21,8 @@
en: 881670b2d7dfb04d
zh: fbf612e945aaf066
/deepseek-ai-dsh-typert-loader/use-this-package/what-gets-registered:
en: 01318fcba39e9b0f
zh: c5f6528a91b335ac
en: 73660724a51afcea
zh: 448a32bb29614c7a
/deepseek-ai-dsh-typert-loader/use-this-package/observable-behavior-and-failures:
en: 01494e9fbd469ec8
zh: 3025d870c0f36e2b
@@ -33,8 +33,8 @@
en: 03fc077e58271e18
zh: 20f8fc02008cee5e
/deepseek-ai-dsh-typert-loader/understand-the-implementation/manifest-validation:
en: eb246aef15ea0532
zh: 7a4a7bcc788b3ba3
en: c7d93c72a224e8fb
zh: 51f0e4b8a043c238
/deepseek-ai-dsh-typert-loader/understand-the-implementation/caching-and-ownership:
en: 38e781e052fdf44e
zh: d8f6b4d119bf9856
@@ -51,8 +51,8 @@
en: f8a0ad3d0fc0eb32
zh: 7649a694b279f5f3
/deepseek-ai-dsh-typert-loader/known-limitations-and-deferred-work:
en: a7e281826b58b218
zh: b4347c965151a1a9
en: 9bc795b850f7ab8a
zh: f3aea571a70bf327
/deepseek-ai-dsh-typert-loader/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+5 -2
View File
@@ -44,7 +44,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
### What gets registered
Each qualifying Loader entry contributes its generated host-face reflection and schema factories to the runtime registry. Registration follows the entry lifecycle: it is withdrawn when the entry or the plugin unmounts, and an import that settles after either owner is gone is discarded.
Each qualifying Loader entry contributes its generated host-face reflection, schema factories, and `TYPERT.invocations` to the runtime registry. The ordinary artifact's getter exposes descriptors from a private Remote info instance; generated direct descriptors also provide named input and invocation helpers. Registration follows the entry lifecycle: it is withdrawn when the entry or the plugin unmounts, and an import that settles after either owner is gone is discarded.
Source compositions requiring strict Remote validation prebuild and load the ordinary `./typert` artifact. The loader registers its invocations in `ctx.typert.local`; it does not recover erased constraints, documentation, or schemas from live Services. Limited descriptor-free source fallback remains a Gateway concern, and `local.hasSeen()` prevents downgrading to it after a registered definition is withdrawn.
### Observable behavior and failures
@@ -66,7 +68,7 @@ The plugin is an incremental scanner mirroring the client-modules node half: eve
### Manifest validation
`validateTypertManifest()` is the module/file boundary: the manifest crosses from a build artifact into the typed registry, so every field is checked. The manifest must name the package that exports it, carry face `host`, hold schema factories, and keep well-formed service, event, object, member, type, and documentation records; invocation descriptors must use strict codecs with factories. Every failure names the package and the defect.
`validateTypertManifest()` checks artifacts before they enter the typed registry. A manifest must name its exporting package, carry face `host`, hold schema factories, and contain well-formed reflection records; invocation descriptors require strict codecs with factories. Validation checks parameter `codec` and any supplied `strictCodec` specs and requires generated direct-method `input` and `invoke` to be callable; `result` remains the sole output codec. Documentation and JSON Schema are optional. Registration never calls the input selector, parser factories, or projection getters; validation inspects only supplied static projection data. Every failure names the package and the defect.
### Caching and ownership
@@ -113,6 +115,7 @@ These limits define what the loader does not discover or register; they are curr
- **Host face only** — discovery imports only the host `./typert` artifact; client runtimes need a separate composition owner before equivalent discovery is added.
- **Explicit entries for nested plugins** — Loader entries are discovered automatically, but plugins nested behind another entry, or not loaded by the Loader at all, need an explicit `packages` entry or direct `ctx.typert.register()` ownership.
- **Cached verdicts never expire** — a package that gains a `./typert` export mid-process needs a restart before the loader registers it.
- **Host readiness remains composition-owned** — consumers requiring strict metadata must wait for registration before serving calls. The lazy info factory does not make asynchronous Loader registration ready; `local.hasSeen()` only protects definitions already observed.
<a id="dev-note"></a>
### Dev Note
+5 -2
View File
@@ -44,7 +44,9 @@ kind: "package-reference"
### 注册什么
每个符合条件的 Loader 配置项都会把其生成的宿主侧反射与 schema factory 贡献给运行时注册表。注册跟随配置项生命周期:配置项或本插件卸载时撤销;在配置项或本插件任一方卸载后才结束的导入操作会被丢弃。
每个符合条件的 Loader 配置项都会把其生成的宿主侧反射、schema factory 和 `TYPERT.invocations` 贡献给运行时注册表。普通产物的 getter 暴露来自私有 Remote info 实例的描述符;生成的直接方法描述符还提供具名输入与调用辅助能力。注册跟随配置项生命周期:配置项或本插件卸载时撤销;在配置项或本插件任一方卸载后才结束的导入操作会被丢弃。
要求严格 Remote 校验的源码组合预先构建并加载普通 `./typert` 产物。loader 将其调用定义注册到 `ctx.typert.local`;它不会从运行中的 Service 恢复已擦除的约束、文档或 schema。受限的无描述符源码回退仍由 Gateway 负责,`local.hasSeen()` 会阻止已注册定义撤销后降级到该回退。
### 可观察行为与失败
@@ -66,7 +68,7 @@ kind: "package-reference"
### Manifest 校验
`validateTypertManifest()` 是模块/文件边界:manifest 从构建产物进入类型化注册表,因此每个字段都会被检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并保持服务、事件、对象、成员、类型与文档记录格式正确;调用描述符必须使用带 factory 的严格编解码器。每次失败都会指名包与缺陷。
`validateTypertManifest()` 在产物进入类型化注册表前执行检查。manifest 必须指名导出它的包、携带 `host` face、持有 schema factory,并包含格式正确的反射记录;调用描述符要求带 factory 的严格 codec。校验检查参数 `codec` 及提供的 `strictCodec` spec,并要求生成直接方法的 `input` 和 `invoke` 可调用;`result` 仍是唯一输出 codec。文档和 JSON Schema 可选。注册绝不调用输入选择器、解析器 factory 或投影 getter;校验只检查已提供的静态投影数据。每次失败都会指名包与缺陷。
### 缓存与归属
@@ -113,6 +115,7 @@ kind: "package-reference"
- **仅宿主侧**——发现机制只会导入宿主侧 `./typert` 产物;在为客户端运行时添加等价的发现机制之前,需要先有独立的组合所有者。
- **嵌套插件需要显式条目**——Loader 配置项会被自动发现,但嵌套在另一配置项之下、或完全不经 Loader 加载的插件,需要显式加入 `packages`,或由其所有者直接调用 `ctx.typert.register()`。
- **缓存结论永不过期**——进程中途新增 `./typert` 导出的包需要重启后 loader 才会注册它。
- **Host 就绪仍由组合负责**——要求严格元数据的消费方必须等待注册完成后再接受调用。惰性 info factory 不会让异步 Loader 注册自动就绪;`local.hasSeen()` 只保护已经观察到的定义。
<a id="dev-note"></a>
### 开发备注
+2
View File
@@ -40,7 +40,9 @@
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/dsh-typert-registry": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"typescript": "^6.0.3",
"zod": "4.4.3"
}
}
+43 -5
View File
@@ -80,8 +80,9 @@ function typertExportOf(pkgName: string, exportsField: unknown): string | undefi
/**
* Narrow a dynamically imported typert module's `TYPERT` export to a
* contribution owned by `pkgName`. This is the module/file boundary: the
* manifest crosses from a build artifact into the typed registry, so every
* field is checked and every failure names the package and the defect.
* manifest crosses from a build artifact into the typed registry. Dispatch
* facts and eager projection fields are checked; computed tool projections
* remain lazy until a consumer requests them. Failures name the package and defect.
* @param pkgName - the package whose typert face was imported.
* @param exported - the module's `TYPERT` export.
* @returns the validated contribution.
@@ -165,6 +166,12 @@ function requireString(pkgName: string, value: Record<string, unknown>, key: str
}
}
function requireOptionalString(pkgName: string, value: Record<string, unknown>, key: string, subject: string): void {
if (value[key] !== undefined && typeof value[key] !== 'string') {
throw new Error(`typert-loader: ${pkgName} ${subject}.${key} must be a string`)
}
}
function requireDocumentation(pkgName: string, value: Record<string, unknown>, subject: string): void {
requireArray(pkgName, value.tags, `${subject}.tags`)
for (const key of ['description', 'summary', 'jsDoc'] as const) {
@@ -199,6 +206,10 @@ function requireInvocation(pkgName: string, value: unknown): void {
requireString(pkgName, invocation, key, 'invocation')
}
const id = invocation.id as string
requireOptionalString(pkgName, invocation, 'description', `invocation "${id}"`)
if (invocation.mode !== undefined && invocation.mode !== 'stream') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" mode must be "stream" when present`)
}
const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`)
if (receiver.kind === 'context') {
requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`)
@@ -214,6 +225,7 @@ function requireInvocation(pkgName: string, value: unknown): void {
const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`)
requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`)
requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`)
requireOptionalString(pkgName, parameter, 'description', `invocation "${id}" parameter`)
const wire = parameter.wire as string
if (wires.has(wire)) {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`)
@@ -231,6 +243,7 @@ function requireInvocation(pkgName: string, value: unknown): void {
}
parameters.set(wire, parameter)
requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`)
if (parameter.strictCodec !== undefined) requireStrictCodec(pkgName, parameter.strictCodec, `invocation "${id}" strict input codec`)
}
if (invocation.cancellation !== undefined) {
const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`)
@@ -256,6 +269,17 @@ function requireInvocation(pkgName: string, value: unknown): void {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`)
}
requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`)
if (invocation.input !== undefined || invocation.invoke !== undefined) {
if (receiver.kind !== 'direct') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" input/invoke require a direct receiver`)
}
if (typeof invocation.input !== 'function') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" has no input() selector`)
}
if (typeof invocation.invoke !== 'function') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" has no invoke() function`)
}
}
if (invocation.sourceLocation !== undefined) {
const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`)
requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`)
@@ -267,6 +291,22 @@ function requireInvocation(pkgName: string, value: unknown): void {
}
}
function requireSchemaProjection(pkgName: string, value: Record<string, unknown>, subject: string): void {
if (typeof value.create !== 'function') {
throw new Error(`typert-loader: ${pkgName} ${subject} has no create() factory`)
}
// Inspect supplied data without invoking the factory's cached projection getters.
const fields = Object.getOwnPropertyDescriptors(value)
for (const key of ['description', 'jsonSchemaError']) {
const field: unknown = fields[key]?.value
if (field !== undefined && typeof field !== 'string') {
throw new Error(`typert-loader: ${pkgName} ${subject}.${key} must be a string`)
}
}
const schema: unknown = fields.jsonSchema?.value
if (schema !== undefined) requireObject(pkgName, schema, `${subject}.jsonSchema`)
}
function requireStrictCodec(pkgName: string, value: unknown, subject: string): void {
const codec = requireObject(pkgName, value, subject)
if (codec.mode !== 'strict') {
@@ -278,9 +318,7 @@ function requireStrictCodec(pkgName: string, value: unknown, subject: string): v
throw new Error(`typert-loader: ${pkgName} ${subject} ${method} must be a function`)
}
}
if (typeof codec.create !== 'function') {
throw new Error(`typert-loader: ${pkgName} ${subject} has no create() factory`)
}
requireSchemaProjection(pkgName, codec, subject)
}
/**
@@ -0,0 +1,35 @@
/** Loading a generated contribution must not request consumer-owned input views or projections. */
import { expect, it, vi } from 'vitest'
import { z } from 'zod'
import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info'
import { validateTypertManifest } from '../src/index.ts'
it('validates invocation facts without selecting input policy or materializing schemas', () => {
const create = vi.fn(() => z.string())
const codec = { mode: 'strict' as const, typeSymbol: 'pkg#Text', create, jsonSchema: { type: 'string' } }
const info = createTypertRemoteInfo({ package: 'pkg', methods: {
'goals/create': {
service: 'goals', invocation: { kind: 'direct' },
parameters: [{ name: 'request', source: 'json', codec, strictCodec: codec }],
result: { ...codec, description: 'Created goal.' },
},
} }, z)
const method = info.methods['goals/create']!
const normal = method.input()
const strict = method.input({ strict: true })
const select = vi.spyOn(method, 'input')
const inputSchema = vi.spyOn(normal, 'jsonSchema', 'get')
const strictSchema = vi.spyOn(strict, 'jsonSchema', 'get')
const inputError = vi.spyOn(strict, 'jsonSchemaError', 'get')
const outputSchema = vi.spyOn(method.result, 'jsonSchema', 'get')
const manifest = { package: 'pkg', face: 'host', schemas: [], model: { services: [], events: [], objects: [] }, invocations: info.descriptors }
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
for (const operation of [create, select, inputSchema, strictSchema, inputError, outputSchema]) {
expect(operation).not.toHaveBeenCalled()
}
expect(strict.jsonSchema).toMatchObject({ properties: { request: { type: 'string' } } })
expect(method.result.jsonSchema).toEqual({ type: 'string', description: 'Created goal.' })
expect(strict.create().parse({ request: 'ship' })).toEqual({ request: 'ship' })
expect(create).toHaveBeenCalledTimes(1)
})
+99 -19
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +10,9 @@ import { PluginPackages } from '@deepseek-ai/dsh-app-boot'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as typertLoader from '@deepseek-ai/dsh-typert-loader'
import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader'
import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info'
import type { TypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol'
import ts from 'typescript'
import { z } from 'zod'
let root: string | undefined
@@ -70,27 +73,50 @@ function typertSource(pkgName: string, entryName: string): string {
].join('\n')
}
function invocationTypertSource(pkgName: string): string {
/** Native fixture imports use current factory source, never a previously built protocol artifact. */
async function writeRemoteInfoRuntime(base: string): Promise<string> {
const source = await readFile(new URL('../../protocol/src/remote-info.ts', import.meta.url), 'utf8')
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext },
})
const module = ts.createSourceFile('remote-info.js', outputText, ts.ScriptTarget.ESNext, true, ts.ScriptKind.JS)
expect(module.statements.filter(ts.isImportDeclaration)).toEqual([])
const path = join(base, 'remote-info.mjs')
await writeFile(path, outputText, { flag: 'wx', mode: 0o600 })
return pathToFileURL(path).href
}
function invocationTypertSource(pkgName: string, factoryUrl: string): string {
return [
'import { z } from \'zod\'',
`import { createTypertRemoteInfo } from ${JSON.stringify(factoryUrl)}`,
'const Text = () => z.string()',
'export const TYPERT = {',
'export const REMOTE_INFO = createTypertRemoteInfo({',
` package: '${pkgName}',`,
' methods: {',
" 'goals/create': {",
" service: 'goals', invocation: { kind: 'direct' },",
" description: 'Create a goal.',",
' parameters: [{',
" name: 'request', source: 'json', description: 'Goal title.',",
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text, jsonSchema: { type: 'string' } },`,
` strictCodec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text, jsonSchema: { type: 'string' } },`,
' }],',
" cancellation: { parameter: 'signal' },",
' result: {',
` mode: 'strict', typeSymbol: '${pkgName}/types#Result', create: Text,`,
" description: 'Created goal.', jsonSchema: { type: 'string' },",
' },',
" sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },",
' },',
' },',
'}, z)',
'export const TYPERT = {',
' package: REMOTE_INFO.package,',
' face: \'host\',',
' schemas: [],',
' model: { services: [], events: [], objects: [] },',
' invocations: [{',
` id: '${pkgName}#goals/create',`,
' service: \'goals\', namespace: \'goals\', method: \'create\',',
' invocation: { kind: \'direct\' },',
' parameters: [{',
' name: \'request\', wire: \'request\', source: \'json\',',
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', create: Text },`,
' }],',
" cancellation: { parameter: 'signal' },",
` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', create: Text },`,
' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },',
' }],',
' invocations: REMOTE_INFO.descriptors,',
'}',
'',
].join('\n')
@@ -147,11 +173,12 @@ describe('typert loader', () => {
expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined()
})
it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => {
it('retains generated flat MethodInfo through native artifact import and loader disposal', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
const factoryUrl = await writeRemoteInfoRuntime(root)
await writePackage(root, '@fixture/invocation', {
typertSource: invocationTypertSource('@fixture/invocation'),
typertSource: invocationTypertSource('@fixture/invocation', factoryUrl),
})
const ctx = await boot()
@@ -166,8 +193,19 @@ describe('typert loader', () => {
cancellation: { parameter: 'signal' },
sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },
})
expect(descriptor?.parameters[0]?.codec.mode).toBe('strict')
if (descriptor?.parameters[0]?.codec.mode === 'strict') {
expect(ctx.typert.local.list()[0]).toBe(descriptor)
if (descriptor === undefined || !isTypertRemoteMethod(descriptor)) throw new Error('Generated direct method is missing')
const artifact = await import(pathToFileURL(join(root, 'node_modules/@fixture/invocation/typert.host.js')).href) as { REMOTE_INFO: TypertRemoteInfo }
expect(artifact.REMOTE_INFO.methods['goals/create']).toBe(descriptor)
expect(descriptor).not.toHaveProperty('metadata')
expect(descriptor.description).toBe('Create a goal.')
expect(descriptor.parameters[0]?.description).toBe('Goal title.')
expect(descriptor.input().create().parse({ request: 'ready' })).toEqual({ request: 'ready' })
expect(() => descriptor.input().create().parse({ request: 1 })).toThrow()
expect(descriptor.result.create().parse('created')).toBe('created')
expect(descriptor.result.jsonSchema).toEqual({ type: 'string', description: 'Created goal.' })
expect(descriptor.parameters[0]?.codec.mode).toBe('strict')
if (descriptor.parameters[0]?.codec.mode === 'strict') {
expect(descriptor.parameters[0].codec.create().parse('request')).toBe('request')
}
@@ -529,6 +567,48 @@ describe('validateTypertManifest', () => {
})).toThrow('object has a missing or empty exportName')
})
it('validates flat method capabilities and eager projections without requiring tool schemas', () => {
const input = vi.fn(() => ({ create: () => z.object({ request: z.string() }) }))
const method = {
...strictInvocation(), invoke: () => undefined, input,
result: { ...strictCodec('pkg#Result'), jsonSchemaError: 'Not projectable.' },
}
const manifest = { ...completeManifest(zodish), invocations: [method] }
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
expect(input).not.toHaveBeenCalled()
const withDocs = {
...method, description: '',
parameters: method.parameters.map(parameter => ({ ...parameter, description: 'Goal title.', strictCodec: parameter.codec })),
result: { ...method.result, description: '' },
}
expect(validateTypertManifest('pkg', { ...manifest, invocations: [withDocs] }).invocations[0]).toBe(withDocs)
const stream = { ...method, mode: 'stream' }
expect(validateTypertManifest('pkg', { ...manifest, invocations: [stream] }).invocations[0]).toBe(stream)
for (const invalid of [
{ ...method, mode: 'unary' }, { ...method, mode: 'future' },
{ ...method, invoke: false }, { ...method, description: 1 },
{ ...method, parameters: method.parameters.map(parameter => ({ ...parameter, description: false })) },
{ ...method, input: null }, { ...method, input: { create: false } },
{ ...method, parameters: method.parameters.map(parameter => ({ ...parameter, strictCodec: { ...parameter.codec, create: false } })) },
{ ...method, parameters: method.parameters.map(parameter => ({
...parameter, strictCodec: { ...parameter.codec, jsonSchema: [] },
})) },
{ ...method, parameters: method.parameters.map(parameter => ({
...parameter, strictCodec: { ...parameter.codec, jsonSchemaError: false },
})) },
{ ...method, result: { ...method.result, jsonSchemaError: false } },
{ ...method, result: { ...method.result, description: 1 } },
{ ...method, invocation: { kind: 'context', context: 'agent', wire: 'agentId', codec: strictCodec('pkg#Agent') } },
]) {
expect(() => validateTypertManifest('pkg', { ...manifest, invocations: [invalid] })).toThrow('typert-loader')
}
const { input: _input, ...withoutInput } = method
const { invoke: _invoke, ...withoutInvoke } = method
for (const invalid of [withoutInput, withoutInvoke]) {
expect(() => validateTypertManifest('pkg', { ...manifest, invocations: [invalid] })).toThrow('typert-loader')
}
})
it('requires and validates strict invocation descriptors', () => {
const base = completeManifest(zodish)
const { invocations: _invocations, ...missingInvocations } = base
+8 -8
View File
@@ -18,8 +18,8 @@
en: c479b5b665822198
zh: 1ffb789c18043baf
/deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method:
en: fae902875c18ef33
zh: ed4332cabe0e3a45
en: 82a3db72a51105ff
zh: 46848784380732be
/deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities:
en: 3db384ae6f215632
zh: 56207184b09d4018
@@ -39,14 +39,14 @@
en: 93b83a9441a5a3c2
zh: c4eb143ddd167a27
/deepseek-ai-dsh-typert-protocol/understand-the-implementation/protocol-maps-and-descriptors:
en: a553d2c60d02df85
zh: c2bd3d630c28290f
en: c202904201402211
zh: 3db1d83683b4fd2a
/deepseek-ai-dsh-typert-protocol/understand-the-implementation/wire-identity-grammar:
en: aac79a7b4682371e
zh: bc9cc513c1ced3e6
/deepseek-ai-dsh-typert-protocol/understand-the-implementation/source-map:
en: 62da405f16379f39
zh: fcbd0f10dd198018
en: 8b878e0e003b86ec
zh: 5d92998ad2964d7c
/deepseek-ai-dsh-typert-protocol/further-exploration:
en: 84191d1b07fb8bb6
zh: 9a5212732ed38ab6
@@ -57,8 +57,8 @@
en: b1c49d5e440d9d41
zh: 87ead78472415189
/deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work:
en: 0be59e6d97923487
zh: 70523fb0dc567ed6
en: 6079caacbcd01f08
zh: 70295e443f35afe8
/deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+11 -2
View File
@@ -48,6 +48,14 @@ A unary method can return `Uint8Array` directly or within nested objects, arrays
A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out, In>`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink<In>()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle<Out, In>`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through.
`@Remote` options contain only `name` and `mode`; input policy belongs to the consumer, not a method annotation. `method.input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue<Input>`, with `strict?: boolean` as its only option. `method.input()` and `{ strict: false }` retain ordinary Remote handling: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively, while explicit JSON values and dictionary keys stay open and dictionary values retain their declared validation. Both named-argument envelopes reject extra arguments. Select one view (`const input = method.input({ strict: true })`) for matching `input.create().parse(raw)` and `input.jsonSchema`; this does not change Host dispatch. The [generator](<../generator/README.md#ordinary-remote-metadata>) owns publication and the paired codecs.
Ordinary `./remote` artifacts export `TYPERT_REMOTE` as a `TypertRemoteInfo`, extending the mountable `TypertRemoteContribution`. Its typed `methods[endpoint]` entries are the same direct descriptor objects found in `descriptors`, with derived `input(options?)` and `invoke(remote, input, signal?)`. Parameter documentation lives in `parameters[].description`; `result` is the single result codec, parser, and optional projection. Unary descriptors omit `mode`; streams use `'stream'`. Context-only receivers remain in `descriptors` and scoped declarations, not `methods`.
`createTypertRemoteInfo(spec, z)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry returns a class instance, not a constructor. Construction retains only the static spec and the caller-supplied Zod runtime; the first `descriptors` or `methods` read builds and caches their shared index. Each input-policy view, its parser, and its optional JSON Schema projection are independently lazy and cached; `method.input().create()` requests the ordinary parser. The raw spec requires Zod factories, while consumer-facing `TypertSchema<T>` promises only `parse(value: unknown): T`. JSON Schema comes from generator projections, not runtime `z.toJSONSchema()`. The leaf has no runtime imports; generated artifacts supply Zod. Metadata and schema access never invokes the network; the root also re-exports the factory but imports Cordis.
The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves its namespace method at call time. It preserves the namespace receiver, optional positional holes, and signal, forwarding the same `Promise<RemoteResult<DecodedOutput>>` or complete `RemoteStreamHandle<Output, Uplink>` without an async wrapper. `DecodedOutput` defaults to the authored output; generation supplies `RemoteDecoded<Output>` only for byte-bearing unary methods, avoiding recursive remapping of ordinary JSON results; `result.create().parse()` retains the authored output type and does not normalize shared byte buffers. Documentation and parsing remain available when either input variant or the result lacks JSON Schema support; each variant's `jsonSchemaError` explains its absent projection. Registry readers use `isTypertRemoteMethod(descriptor)` from the same pure entry to narrow a base descriptor to these direct capabilities.
### Associating Host objects and Contexts with wire identities
Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. A Host Context adapter owns the stable wire declaration and resolves wire identities to live Contexts. A Client Context adapter maps in both directions because scoped calls originate from a Client Context and forwarded Host events resolve their explicit wire identity there. Host composition may override its synchronous or asynchronous resolver. A resolver that refuses on policy grounds throws `RemoteError` with its own code, which reaches the caller unchanged.
@@ -93,7 +101,7 @@ The package keeps strict reflection in the compiler: decorator initializers reta
### Protocol maps and descriptors
The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, the uplink codec, cancellation, and result codecs.
The merge-extensible protocol maps keep static associations in the type system, while runtime providers register resolution with `ctx.typert`; the map names and shapes live in [`src/types.ts`](src/types.ts). `InvocationDescriptor` is the shared runtime form consumed by the registry, the Gateway, and the Client Remote, covering direct and Context receivers, JSON and lookup parameters, scope projections, the uplink codec, cancellation, result codecs, and documentation. Generated `TypertRemoteMethod` objects extend this descriptor directly.
`RemoteInvocation.uplinkWindow` optionally describes the carrier's complete-frame inbox capacity and the UTF-8 envelope cost added to compact JSON items. Its absence means the carrier has no encoded-frame inbox limit; consumers still own their local buffering and acknowledgement policy.
@@ -108,6 +116,7 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot
| [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation |
| [`src/json-value.ts`](src/json-value.ts) | `isRemoteJsonValue` and `isRemoteUplinkItem`, the lossless JSON checks every carrier shares |
| [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer |
| [`src/remote-info.ts`](src/remote-info.ts) | Pure lazy info factory and direct-method type guard |
| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `RemoteStream`, `RemoteStreamHandle`, `PeerScope`, `RemoteInvocation`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` |
</details>
@@ -143,7 +152,7 @@ No direct effect; the declared contracts reach a request only when an assembly p
These limits define what the declarations can represent; they are current package constraints, not a task backlog.
- **Decorator markers are minimal** — markers contain only the method name and the direct or Context invocation mode; parameter, result, lookup, and schema reflection require the Typert build pipeline.
- **Decorator markers are minimal** — markers contain the method name, optional export alias and stream mode, and direct or Context receiver selection; parameter, result, lookup, schema, and documentation metadata require the Typert build pipeline.
- **Remote signatures are restricted** — decorators accept only public, non-static instance methods with string names, and source-mode execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures.
<a id="dev-note"></a>
+11 -2
View File
@@ -48,6 +48,14 @@ export class GoalService extends TypertRemoteService {
流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out, In>`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink<In>()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle<Out, In>`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。
`@Remote` 选项仅包含 `name` 和 `mode`;输入策略由消费方选择,而非方法注解。`method.input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue<Input>`,唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 保留普通 Remote 处理方式:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段,而显式 JSON 值和字典键保持开放,字典值仍按声明校验。两种具名参数封装都拒绝额外参数。选择一个视图(`const input = method.input({ strict: true })`),即可通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影;这不会改变 Host 分发。[生成器](<../generator/README.zh.md#ordinary-remote-metadata>)负责发布及配套 codec。
普通 `./remote` 产物将 `TYPERT_REMOTE` 导出为 `TypertRemoteInfo`,它扩展可挂载的 `TypertRemoteContribution`。类型化的 `methods[endpoint]` 条目就是 `descriptors` 中同一个直接方法描述符对象,并带有派生的 `input(options?)` 和 `invoke(remote, input, signal?)`。参数文档位于 `parameters[].description`;`result` 是唯一的结果 codec、解析器和可选投影。一元描述符省略 `mode`;流使用 `'stream'`。仅使用 Context 接收器的方法保留在 `descriptors` 和作用域声明中,不进入 `methods`。
已发布的纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供 `createTypertRemoteInfo(spec, z)`,返回类实例而非构造函数。构造时只保留静态 spec 和调用方传入的 Zod 运行时;首次读取 `descriptors` 或 `methods` 时才构建并缓存两者共享的索引。每种输入策略的视图、解析器及可选 JSON Schema 投影都独立惰性求值并缓存;`method.input().create()` 请求普通解析器。原始 spec 要求 Zod factory,而面向消费方的 `TypertSchema<T>` 仅保证 `parse(value: unknown): T`。JSON Schema 来自生成器投影,而非运行时 `z.toJSONSchema()`。纯叶入口没有运行时导入;Zod 由生成产物提供。读取元数据和 schema 绝不发起网络调用;根入口也重新导出该 factory,但会导入 Cordis。
调用器接收调用方拥有、已挂载且无作用域的 Client Remote,并在调用时解析其命名空间方法。它保留命名空间接收者、可选位置参数空位和 signal,不使用 async 包装,直接转发同一个 `Promise<RemoteResult<DecodedOutput>>` 或完整 `RemoteStreamHandle<Output, Uplink>`。`DecodedOutput` 默认采用原始输出类型;生成器仅为含字节的一元方法指定 `RemoteDecoded<Output>`,避免递归重映射普通 JSON 结果;`result.create().parse()` 保留原始输出类型,不会规范化共享字节缓冲区。任一输入变体或结果不支持 JSON Schema 时,文档与解析能力仍然保留;各变体的 `jsonSchemaError` 说明其投影缺失的原因。Registry 读取方使用同一纯入口提供的 `isTypertRemoteMethod(descriptor)`,将基础描述符收窄为这些直接调用能力。
### 把 Host 对象与 Context 关联到 wire identity
复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host Context 适配器拥有稳定 wire 声明,并把 wire identity 解析为活跃 Context。Client Context 适配器需要双向映射,因为作用域调用从 Client Context 发起,而转发的 Host 事件要在 Client 侧解析其显式 wire identity。Host 组合可以覆盖其同步或异步解析器。因策略原因拒绝解析的解析器会抛出带有自身错误码的 `RemoteError`,该码原样到达调用方。
@@ -93,7 +101,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect
### 协议映射与描述符
可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、上行编解码器、取消与结果编解码器。
可合并扩展的协议映射在类型系统中保留静态关联,运行时提供方则向 `ctx.typert` 注册解析;映射的名称与形状见 [`src/types.ts`](src/types.ts)。`InvocationDescriptor` 是注册表、Gateway 与 Client Remote 共同消费的共享运行时形式,涵盖直接与 Context 接收者、JSON 与查找参数、作用域投影、上行编解码器、取消、结果编解码器与文档。生成的 `TypertRemoteMethod` 对象直接扩展该描述符。
`RemoteInvocation.uplinkWindow` 可选地描述载体完整帧 inbox 的容量,以及紧凑 JSON 项之外的 UTF-8 封装开销。字段缺省表示载体没有编码帧 inbox 限额;消费者仍负责本地缓存与确认策略。
@@ -108,6 +116,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect
| [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 |
| [`src/json-value.ts`](src/json-value.ts) | 各载体共享的无损 JSON 校验 `isRemoteJsonValue` 与 `isRemoteUplinkItem` |
| [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` |
| [`src/remote-info.ts`](src/remote-info.ts) | 纯惰性 info factory 与直接方法类型守卫 |
| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`RemoteStream`、`RemoteStreamHandle`、`PeerScope`、`RemoteInvocation`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` |
</details>
@@ -143,7 +152,7 @@ Host 装配以转发给消费方的 Cordis 事件扩展 `TypertRemoteEventSelect
这些限制说明声明能表示什么;它们是当前包约束,不是任务积压。
- **装饰器标记是最小化的**——标记只包含方法名与直接调用或 Context 调用模式;参数、结果、查找与 schema 反射需要 Typert 构建流水线。
- **装饰器标记是最小化的**——标记包含方法名、可选的导出别名与流模式,以及直接或 Context 接收器选择;参数、结果、查找、schema 与文档元数据需要 Typert 构建流水线。
- **Remote 签名受限**——装饰器只接受具有字符串名称的公开、非静态实例方法,源码模式执行无法表示重载、解构、默认参数或剩余参数签名。
<a id="dev-note"></a>
+6 -1
View File
@@ -22,6 +22,10 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./remote-info": {
"types": "./lib/types/remote-info.d.ts",
"default": "./lib/types/remote-info.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -32,7 +36,8 @@
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-brand": "workspace:*"
"@deepseek-ai/dsh-brand": "workspace:*",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
+45 -23
View File
@@ -12,6 +12,7 @@ export { RemoteError, remoteErrorOf } from './remote-error.ts'
export { TYPERT_OWNED_VALUE, isTypertOwnedValue, typertOwnedValue } from './owned-value.ts'
export type { TypertOwnedValue } from './owned-value.ts'
export { isRemoteJsonValue, isRemoteUplinkItem } from './json-value.ts'
export { createTypertRemoteInfo, isTypertRemoteMethod } from './remote-info.ts'
const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
@@ -30,6 +31,7 @@ export type {
InvocationSourceLocation,
PeerId,
PeerScope,
RemoteDecoded,
RemoteErrorCode,
RemoteErrorDetailsMap,
RemoteFailure,
@@ -64,6 +66,14 @@ export type {
TypertRemoteScopeMap,
TypertRemoteScopeNamespace,
TypertRemoteContribution,
TypertRemoteInfo,
TypertRemoteInputOptions,
TypertRemoteMethod,
TypertRemoteValue,
TypertRemoteCodecSpec,
TypertRemoteParameterSpec,
TypertRemoteMethodSpec,
TypertRemoteSpec,
TypertRemoteEvent,
TypertRemoteEventSelection,
TypertRemoteMap,
@@ -105,10 +115,12 @@ export interface RemoteMethodMarker {
readonly invocation: RemoteInvocationMarker
}
/** Options for a non-unary Remote method. */
/** Explicit endpoint and stream choices for one Remote method. */
export interface RemoteMethodOptions {
/** Exported endpoint method; omission retains the implementation member name. */
readonly name?: string
/** `stream`: deliver each Iterable item over the shared logical-stream carrier. */
readonly mode: 'stream'
readonly mode?: 'stream'
}
type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
@@ -129,6 +141,12 @@ interface StoredRemoteMethodMarker {
readonly invocation: RemoteInvocationMarker
}
/** Marker fields selected by decorator arguments. */
interface RemoteMarkerOptions {
readonly mode?: 'stream'
readonly exportName?: string
}
interface StoredRemoteMethod extends StoredRemoteMethodMarker {
readonly method: string
}
@@ -147,7 +165,7 @@ const REMOTE_METHOD_DESCRIPTOR = '@deepseek-ai/dsh-typert-protocol/remote-method
* a Host composition to read it.
* @param service - owning Service instance, normally `this`.
* @param serviceKey - exact Cordis service key.
* @param options - optional distinct wire namespace.
* @param options - optional wire namespace.
* @returns a frozen, inspectable binding with no compiler-injected metadata.
*/
export function bindTypertRemote<Service extends object>(
@@ -172,7 +190,7 @@ export abstract class TypertRemoteService<out T = never> extends Service<T> {
* Register the Service and bind the same key to Typert Gateway.
* @param ctx - owning Cordis Context.
* @param serviceKey - exact Cordis service key and default wire namespace.
* @param options - optional distinct wire namespace.
* @param options - optional wire namespace.
*/
protected constructor(ctx: Context, serviceKey: string, options: TypertGatewayBindingOptions = {}) {
super(ctx, serviceKey)
@@ -201,8 +219,8 @@ export function Remote<This extends object, Args extends unknown[], Result>(
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void
/**
* Mark one public instance method under an exported name or as a logical stream.
* @param option - endpoint method name or stream delivery mode.
* Mark one public instance method with an endpoint name or stream delivery.
* @param option - endpoint name or Remote options for name and mode.
* @returns a standard method decorator.
*/
export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator
@@ -212,33 +230,39 @@ export function Remote<This extends object, Args extends unknown[], Result>(
): void | RemoteMethodDecorator {
if (typeof methodExportOrOptions === 'string') {
validateName('Remote export name', methodExportOrOptions)
return remoteDecorator({ kind: 'direct' }, undefined, methodExportOrOptions)
return remoteDecorator({ kind: 'direct' }, { exportName: methodExportOrOptions })
}
if (typeof methodExportOrOptions === 'object') {
if (remoteOptionMode(methodExportOrOptions) !== 'stream'
|| Reflect.ownKeys(methodExportOrOptions).length !== 1) {
throw new TypeError('typert-protocol: Remote options must contain exactly mode: "stream"')
}
return remoteDecorator({ kind: 'direct' }, 'stream')
validateRemoteOptions(methodExportOrOptions)
return remoteDecorator({ kind: 'direct' }, {
...(methodExportOrOptions.mode === undefined ? {} : { mode: methodExportOrOptions.mode }),
...(methodExportOrOptions.name === undefined ? {} : { exportName: methodExportOrOptions.name }),
})
}
if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing')
addMarkerInitializer(context, { kind: 'direct' })
}
function remoteOptionMode(options: object): unknown {
return Reflect.get(options, 'mode') as unknown
const REMOTE_OPTION_KEYS = new Set(['name', 'mode'])
function validateRemoteOptions(options: RemoteMethodOptions): void {
const keys = Reflect.ownKeys(options)
if (keys.length === 0 || keys.some(key => typeof key !== 'string' || !REMOTE_OPTION_KEYS.has(key))
|| (Object.hasOwn(options, 'mode') && options.mode !== 'stream')) {
throw new TypeError('typert-protocol: Remote options require name or mode: "stream"')
}
if (options.name !== undefined) validateName('Remote export name', options.name)
}
function remoteDecorator(
invocation: RemoteInvocationMarker,
mode?: 'stream',
exportName?: string,
options: RemoteMarkerOptions = {},
): RemoteMethodDecorator {
return function <This extends object, Args extends unknown[], Result>(
_method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void {
addMarkerInitializer(context, invocation, mode, exportName)
addMarkerInitializer(context, invocation, options)
}
}
@@ -254,7 +278,7 @@ export function RemoteScope(
): RemoteMethodDecorator {
validateName('Scope key', key)
if (exportName !== undefined) validateName('Remote export name', exportName)
return remoteDecorator({ kind: 'context', context: key }, undefined, exportName)
return remoteDecorator({ kind: 'context', context: key }, exportName === undefined ? {} : { exportName })
}
/**
@@ -290,8 +314,7 @@ function readRemoteMethodDescriptor(prototype: object): RemoteMethodDescriptorV1
function addMarkerInitializer<This extends object>(
context: RemoteInitializerContext<This>,
invocation: RemoteInvocationMarker,
mode?: 'stream',
exportName?: string,
options: RemoteMarkerOptions = {},
): void {
if (context.private || context.static || typeof context.name !== 'string') {
throw new TypeError('typert-protocol: Remote decorators require a public instance method with a string name')
@@ -302,7 +325,7 @@ function addMarkerInitializer<This extends object>(
if (prototype === null) {
throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`)
}
mark(prototype, method, invocation, mode, exportName)
mark(prototype, method, invocation, options)
})
}
@@ -310,8 +333,7 @@ function mark(
prototype: object,
method: string,
invocation: RemoteInvocationMarker,
mode?: 'stream',
exportName?: string,
{ mode, exportName }: RemoteMarkerOptions,
): void {
const descriptor = readRemoteMethodDescriptor(prototype)
const marker: StoredRemoteMethod = Object.freeze({
+161
View File
@@ -0,0 +1,161 @@
/** Lazy assembly of generated Remote definitions using caller-supplied Zod; no runtime imports. */
import type { z } from 'zod'
import type {
InvocationDescriptor,
TypertClientRemote,
TypertRemoteCodecSpec,
TypertRemoteInfo,
TypertRemoteInputOptions,
TypertRemoteMethod,
TypertRemoteParameterSpec,
TypertRemoteSpec,
TypertRemoteValue,
} from './types.ts'
type Method = TypertRemoteMethod<object, unknown> | TypertRemoteMethod<object, unknown, 'stream', unknown>
type Parameter = TypertRemoteParameterSpec & { readonly wire: string }
type Index = Pick<TypertRemoteInfo, 'descriptors' | 'methods'>
/**
* Retain one generated definition per endpoint without constructing parsers or projections.
* The class instance builds its shared descriptor/method index on first access;
* input schemas and JSON Schema projections are cached only when requested.
* @param spec - compiler-produced method facts and Zod factories.
* @param zod - the generated artifact's Zod runtime for named-input parsers.
* @returns a lazy, mountable info instance with no registration or transport effects.
*/
export function createTypertRemoteInfo(spec: TypertRemoteSpec, zod: typeof z): TypertRemoteInfo {
return new RemoteInfo(spec, zod)
}
class RemoteInfo implements TypertRemoteInfo {
private index?: Index
constructor(private readonly spec: TypertRemoteSpec, private readonly zod: typeof z) {}
get package(): string { return this.spec.package }
get descriptors(): readonly InvocationDescriptor[] { return this.resolve().descriptors }
get methods(): TypertRemoteInfo['methods'] { return this.resolve().methods }
private resolve(): Index {
if (this.index !== undefined) return this.index
const zod = this.zod
const descriptors: InvocationDescriptor[] = []
const methods = Object.create(null) as Record<string, Method>
for (const [endpoint, definition] of Object.entries(this.spec.methods)) {
const separator = endpoint.indexOf('/')
const namespace = endpoint.slice(0, separator)
const method = endpoint.slice(separator + 1)
const parameters = definition.parameters.map(parameter => ({ ...parameter, wire: parameter.wire ?? parameter.name }))
const descriptor = {
...definition,
id: `${this.spec.package}#${endpoint}`,
namespace,
method,
parameters,
result: definition.result.description === undefined ? definition.result : new ResultValue(definition.result),
}
if (definition.invocation.kind === 'direct') {
const inputs: [NamedInput?, NamedInput?] = []
const direct = Object.assign(descriptor, {
input(options?: TypertRemoteInputOptions) {
const strict = options?.strict === true
return inputs[strict ? 1 : 0] ??= new NamedInput(parameters, strict, zod)
},
invoke(remote: TypertClientRemote, input: object, signal?: AbortSignal) {
const named = input as Readonly<Record<string, unknown>>
const values = parameters.map(parameter => named[parameter.wire])
if (definition.cancellation !== undefined) values.push(signal)
// Namespace access retains the Gateway's mount checks and scoped receiver policy.
const receiver = Reflect.get(remote, namespace) as object
const call = Reflect.get(receiver, method) as (...args: unknown[]) => ReturnType<Method['invoke']>
return call.apply(receiver, values)
},
})
// The generated namespace return type is selected by this same descriptor's mode.
methods[endpoint] = direct as Method
}
descriptors.push(descriptor)
}
return this.index = { descriptors, methods }
}
}
/**
* Narrow a Registry descriptor to its generated direct-method capabilities.
* @param descriptor - a live descriptor, including possible source fallback or Context-only receivers.
* @returns whether generated named input and direct invocation are available.
*/
export function isTypertRemoteMethod(descriptor: InvocationDescriptor): descriptor is Method {
return descriptor.invocation.kind === 'direct' && descriptor.result.mode === 'strict'
&& 'input' in descriptor && typeof descriptor.input === 'function'
&& 'invoke' in descriptor && typeof descriptor.invoke === 'function'
}
function optional(parameter: Parameter): boolean {
return parameter.source === 'json' && parameter.acceptsUndefined === true
}
class NamedInput implements TypertRemoteValue<object> {
private schema?: z.ZodType<object>
private projection?: Pick<TypertRemoteValue, 'jsonSchema' | 'jsonSchemaError'>
constructor(
private readonly parameters: readonly Parameter[],
private readonly strict: boolean,
private readonly zod: typeof z,
) {}
private codec(parameter: Parameter): TypertRemoteCodecSpec {
return this.strict ? parameter.strictCodec : parameter.codec
}
readonly create = (): z.ZodType<object> => this.schema ??= this.zod.strictObject(Object.fromEntries(this.parameters.map((parameter) => {
const value = this.codec(parameter).create()
return [parameter.wire, optional(parameter) ? value.optional() : value.nonoptional()]
})))
get jsonSchema(): TypertRemoteValue['jsonSchema'] { return this.project().jsonSchema }
get jsonSchemaError(): string | undefined { return this.project().jsonSchemaError }
private project(): NonNullable<NamedInput['projection']> {
if (this.projection !== undefined) return this.projection
const unavailable = this.parameters.find(parameter => this.codec(parameter).jsonSchema === undefined)
if (unavailable !== undefined) {
return this.projection = { jsonSchemaError: this.codec(unavailable).jsonSchemaError ?? `No JSON Schema for parameter ${unavailable.wire}` }
}
return this.projection = {
jsonSchema: {
type: 'object',
properties: Object.fromEntries(this.parameters.map(parameter => [parameter.wire, {
...this.codec(parameter).jsonSchema,
...(parameter.description === undefined ? {} : { description: parameter.description }),
}])),
required: this.parameters.filter(parameter => !optional(parameter)).map(parameter => parameter.wire),
additionalProperties: false,
},
}
}
}
class ResultValue implements TypertRemoteCodecSpec {
private projection?: { readonly value: TypertRemoteValue['jsonSchema'] }
constructor(private readonly spec: TypertRemoteCodecSpec) {}
get mode(): 'strict' { return this.spec.mode }
get typeSymbol(): string { return this.spec.typeSymbol }
get create(): TypertRemoteCodecSpec['create'] { return this.spec.create }
get decode(): TypertRemoteCodecSpec['decode'] { return this.spec.decode }
get encode(): TypertRemoteCodecSpec['encode'] { return this.spec.encode }
get description(): string | undefined { return this.spec.description }
get jsonSchemaError(): string | undefined { return this.spec.jsonSchemaError }
get jsonSchema(): TypertRemoteValue['jsonSchema'] {
if (this.projection === undefined) {
const schema = this.spec.jsonSchema
this.projection = { value: schema === undefined ? undefined : { ...schema, description: this.spec.description } }
}
return this.projection.value
}
}
+99 -6
View File
@@ -6,6 +6,7 @@
import type { Context, Events } from '@deepseek-ai/cordis'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ZodType } from 'zod'
import type { TypertOwnedValue } from './owned-value.ts'
declare const LOOKUP_HOST: unique symbol
@@ -266,26 +267,29 @@ export interface TypertSchema<Output = unknown> {
parse(value: unknown): Output
}
/** Native byte views reconstructed by a Remote carrier have ordinary ArrayBuffer backing. */
export type RemoteDecoded<Value> = Value extends Uint8Array ? Uint8Array<ArrayBuffer>
: Value extends string | number | boolean | bigint | symbol ? Value
: Value extends object ? { [Key in keyof Value]: RemoteDecoded<Value[Key]> } : Value
/** Codec attached to one invocation parameter or result. */
export type TypertCodec =
| {
export 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'
@@ -295,6 +299,8 @@ export type TypertCodec =
export 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. */
@@ -363,6 +369,8 @@ export 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
}
@@ -438,6 +446,91 @@ export interface TypertRemoteContribution {
readonly descriptors: readonly InvocationDescriptor[]
}
/** Generated parser and optional lossless tool-schema projection of one Remote value. */
export interface TypertRemoteValue<Value = unknown> {
/** Materialize the generated parser without loading the owning Service. */
readonly create: () => TypertSchema<Value>
/** Source-owned value documentation, independent of tool projection support. */
readonly description?: string | undefined
/** Present when the value is representable in the tool-schema subset without losing constraints; may be derived lazily. */
readonly jsonSchema?: Readonly<Record<string, unknown>> | undefined
/** Projection failure for consumers that require JSON Schema; parsing remains available. */
readonly jsonSchemaError?: string | undefined
}
/** Consumer-owned input validation; never changes Host dispatch or the business method declaration. */
export interface TypertRemoteInputOptions {
/** Reject undeclared fields in input objects; explicit dictionaries and JSON values remain open. */
readonly strict?: boolean
}
/** A direct descriptor; DecodedOutput differs from its parser output only for carrier-reconstructed unary bytes. */
export type TypertRemoteMethod<Input extends object, Output, Mode extends 'unary' | 'stream' = 'unary', Uplink = never, DecodedOutput = Output> =
Omit<InvocationDescriptor, 'mode' | 'invocation' | 'result'>
& (Mode extends 'stream' ? { readonly mode: 'stream' } : { readonly mode?: never }) & {
/** Named invocation selects a direct Service method, not a Context receiver. */
readonly invocation: { readonly kind: 'direct' }
/**
* Select matching named-input parsing and tool-schema projection without changing dispatch.
* @param options - consumer-owned strictness; omission preserves ordinary Remote object handling.
* @returns the cached view for this policy; its parser and projection remain lazy.
*/
input(options?: TypertRemoteInputOptions): TypertRemoteValue<Input>
/** The descriptor's result or stream-item parser; parsing alone does not normalize native byte backing. */
readonly result: Extract<TypertCodec<Output>, { readonly mode: 'strict' }>
/**
* Invoke the mounted namespace method with named wire arguments.
* @param remote - caller-owned, unscoped Client Remote service; its mount owns availability.
* @param input - decoded named wire arguments.
* @param signal - caller-owned invocation lifetime.
* @returns the carrier-decoded unary result or complete stream handle, including uplink and disposal.
*/
invoke(remote: TypertClientRemote, input: Input, signal?: AbortSignal): Mode extends 'stream'
? RemoteStreamHandle<Output, Uplink>
: Promise<RemoteResult<DecodedOutput>>
}
/** Ordinary contribution whose method index references its direct invocation descriptors. */
export interface TypertRemoteInfo<Methods extends Readonly<Record<string,
TypertRemoteMethod<object, unknown> | TypertRemoteMethod<object, unknown, 'stream', unknown>
>> = Readonly<Record<string, TypertRemoteMethod<object, unknown> | TypertRemoteMethod<object, unknown, 'stream', unknown>>>>
extends TypertRemoteContribution {
/** Exact generated keys and types; Context-only receivers are available only through descriptors. */
readonly methods: Methods
}
/** Generated Zod factory and optional tool projection for one invocation value. */
export interface TypertRemoteCodecSpec extends Omit<Extract<TypertCodec, { readonly mode: 'strict' }>, 'create'> {
/** Materialize the generated Zod schema only when a parser is requested. */
readonly create: () => ZodType
}
/** One ordered parameter; omitted wire names resolve to the source parameter name. */
export interface TypertRemoteParameterSpec extends Omit<InvocationParameterDescriptor, 'wire' | 'codec'> {
/** Explicit wire alias, notably for a Host lookup identity. */
readonly wire?: string
/** Ordinary Remote parser and projection for this parameter. */
readonly codec: TypertRemoteCodecSpec
/** Consumer-selected counterpart rejecting undeclared fields in explicit input objects. */
readonly strictCodec: TypertRemoteCodecSpec
}
/** Static method facts; endpoint identity and named-call helpers are derived by the runtime factory. */
export interface TypertRemoteMethodSpec extends Omit<InvocationDescriptor, 'id' | 'namespace' | 'method' | 'parameters' | 'result'> {
/** Ordered Host parameters, including their wire representations. */
readonly parameters: readonly TypertRemoteParameterSpec[]
/** Successful unary result or stream item, with optional native-byte handling. */
readonly result: TypertRemoteCodecSpec
}
/** Compiler-produced method definitions, independent of services, registration, and transport. */
export interface TypertRemoteSpec {
/** Package owning these methods. */
readonly package: string
/** Public endpoints; each method's invocation facts occur only once. */
readonly methods: Readonly<Record<`${string}/${string}`, TypertRemoteMethodSpec>>
}
/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */
export interface TypertClientRemote extends TypertRemoteNamespaceMap {
/**
@@ -0,0 +1,10 @@
import { readFile } from 'node:fs/promises'
import { expect, it } from 'vitest'
it.each(['../lib/index.js', '../lib/types/remote-info.js'])(
'keeps the published protocol entry %s free of Zod runtime imports',
async (entry) => {
const source = await readFile(new URL(entry, import.meta.url), 'utf8')
expect(source).not.toMatch(/\b(?:from|import|require)\s*(?:\(\s*)?['"]zod(?:\/[^'"]*)?['"]/)
},
)
@@ -305,11 +305,12 @@ describe('typert-protocol Remote declarations', () => {
expect(() => Remote('bad name')).toThrow('export name')
expect(() => Remote('.')).toThrow('export name')
expect(() => Remote('..')).toThrow('export name')
expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode')
expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode')
expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('Remote options require')
expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('Remote options require')
const bogusMode: string = 'duplex'
expect(() => Remote({ mode: bogusMode } as { mode: 'stream' }))
.toThrow('Remote options must contain exactly mode: "stream"')
.toThrow('Remote options require name or mode: "stream"')
expect(() => Remote({})).toThrow('Remote options require')
expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key')
expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name')
@@ -323,6 +324,23 @@ describe('typert-protocol Remote declarations', () => {
}
})
it('retains transport identity and mode for renamed streams', async () => {
const ctx = new Context()
class ItemsService extends TypertRemoteService {
constructor(owner: Context) { super(owner, 'itemsFixture') }
@Remote({ name: 'items', mode: 'stream' })
async *read(): AsyncIterable<string> { yield 'first' }
}
try {
expect(remoteMethods(new ItemsService(ctx))).toEqual([{
method: 'read', exportName: 'items', mode: 'stream', invocation: { kind: 'direct' },
}])
} finally {
await ctx.fiber.dispose()
}
})
it('rejects prototype-less initialization and conflicting markers', () => {
const method: (this: object) => void = function (this: object): void {}
const direct: Array<(this: object) => void> = []
@@ -0,0 +1,230 @@
/** Factory regressions use static definitions without loading a Controller or a Remote runtime. */
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { createTypertRemoteInfo, isTypertRemoteMethod } from '../src/remote-info.ts'
import type { TypertClientRemote, TypertRemoteCodecSpec, TypertRemoteMethodSpec } from '../src/types.ts'
const text: TypertRemoteCodecSpec = {
mode: 'strict', typeSymbol: '@fixture/types#Text', create: () => z.string(), jsonSchema: { type: 'string' },
}
function definition(overrides: Partial<TypertRemoteMethodSpec> = {}): TypertRemoteMethodSpec {
return {
service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.',
parameters: [{ name: 'request', source: 'json', description: 'Goal name.', codec: text, strictCodec: text }],
result: text,
...overrides,
}
}
describe('lazy Remote info', () => {
it('returns a class instance and defers its index, schemas, and projections independently', () => {
let inventoryReads = 0
let inputProjectionReads = 0
let outputProjectionReads = 0
const create = vi.fn(() => z.string())
const input = { ...text, create, get jsonSchema() { inputProjectionReads++; return { type: 'string' } } }
const output = { ...text, description: 'Created name.', get jsonSchema() { outputProjectionReads++; return { type: 'string' } } }
const spec = {
package: '@fixture/goals',
get methods() { inventoryReads++; return { 'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: input, strictCodec: input }], result: output }) } },
}
const runtime = { ...z }
const strictObject = vi.spyOn(runtime, 'strictObject')
const info = createTypertRemoteInfo(spec, runtime)
expect(Object.getPrototypeOf(info)).not.toBe(Object.prototype)
expect(info.package).toBe('@fixture/goals')
expect(inventoryReads).toBe(0)
expect(create).not.toHaveBeenCalled()
const method = info.methods['goals/create']!
expect(inventoryReads).toBe(1)
expect(info.descriptors[0]).toBe(method)
expect(info.methods).toBe(info.methods)
expect(info.descriptors).toBe(info.descriptors)
expect(inventoryReads).toBe(1)
expect(inputProjectionReads + outputProjectionReads).toBe(0)
expect(create).not.toHaveBeenCalled()
expect(strictObject).not.toHaveBeenCalled()
expect(method).not.toHaveProperty('metadata')
expect(method).not.toHaveProperty('output')
expect(method.mode).toBeUndefined()
expect(isTypertRemoteMethod(method)).toBe(true)
const inputView = method.input()
expect(strictObject).not.toHaveBeenCalled()
const parser = inputView.create()
expect(method.input().create()).toBe(parser)
expect(strictObject).toHaveBeenCalledTimes(1)
expect(create).toHaveBeenCalledTimes(1)
expect(parser.parse({ request: 'ship' })).toEqual({ request: 'ship' })
expect(inputProjectionReads + outputProjectionReads).toBe(0)
const projection = method.input().jsonSchema
expect(projection).toEqual({ type: 'object', properties: { request: { type: 'string' } }, required: ['request'], additionalProperties: false })
expect(method.input().jsonSchema).toBe(projection)
const reads = inputProjectionReads
expect(method.input().jsonSchemaError).toBeUndefined()
expect(inputProjectionReads).toBe(reads)
expect(outputProjectionReads).toBe(0)
expect(method.result.create).toBe(output.create)
const resultProjection = method.result.jsonSchema
expect(resultProjection).toEqual({ type: 'string', description: 'Created name.' })
expect(method.result.jsonSchema).toBe(resultProjection)
expect(outputProjectionReads).toBe(1)
})
it('selects and caches independent parser/projection views without changing dispatch codecs', () => {
const create = vi.fn(() => z.object({ title: z.string() }))
const createStrict = vi.fn(() => z.strictObject({ title: z.string() }))
const codec = { ...text, create, jsonSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'], additionalProperties: true } }
const strictCodec = { ...codec, create: createStrict, jsonSchema: { ...codec.jsonSchema, additionalProperties: false } }
const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec, strictCodec }] }),
} }, z)
const method = info.methods['goals/create']!
const normal = method.input()
const strict = method.input({ strict: true })
expect(method.input({ strict: false })).toBe(normal)
expect(method.input({})).toBe(normal)
expect(method.input({ strict: true })).toBe(strict)
expect(strict).not.toBe(normal)
expect(create).not.toHaveBeenCalled()
expect(createStrict).not.toHaveBeenCalled()
expect(normal.jsonSchema).toMatchObject({ properties: { request: { additionalProperties: true } } })
expect(strict.jsonSchema).toMatchObject({ properties: { request: { additionalProperties: false } } })
expect(create).not.toHaveBeenCalled()
expect(createStrict).not.toHaveBeenCalled()
const extra = { request: { title: 'ship', ignored: true } }
expect(normal.create().parse(extra)).toEqual({ request: { title: 'ship' } })
expect(create).toHaveBeenCalledTimes(1)
expect(createStrict).not.toHaveBeenCalled()
expect(() => strict.create().parse(extra)).toThrow()
expect(strict.create().parse({ request: { title: 'ship' } })).toEqual({ request: { title: 'ship' } })
expect(createStrict).toHaveBeenCalledTimes(1)
expect(method.parameters[0]?.codec).toBe(codec)
expect(normal.create().parse(extra)).toEqual({ request: { title: 'ship' } })
})
it('preserves named omissions, lookup aliases, strict outer fields, and positional holes', () => {
const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/create': definition({
parameters: [
{ name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'agent', acceptsUndefined: true, description: 'Owning Agent.', codec: text, strictCodec: text },
{ name: 'label', source: 'json', acceptsUndefined: true, codec: text, strictCodec: text },
],
cancellation: { parameter: 'signal' },
scope: { context: 'agent', wire: 'agentId' },
}),
} }, z)
const method = info.methods['goals/create']!
const parser = method.input().create()
expect(parser.parse({ agentId: 'agent' })).toEqual({ agentId: 'agent' })
expect(parser.parse({ agentId: 'agent', label: undefined })).toEqual({ agentId: 'agent', label: undefined })
for (const invalid of [null, [], {}, { agentId: undefined }, { agentId: 'agent', label: null }, { agentId: 'agent', extra: true }]) {
expect(() => parser.parse(invalid)).toThrow()
}
expect(method.input().jsonSchema?.required).toEqual(['agentId'])
expect(method.input().jsonSchema).toMatchObject({ properties: { agentId: { description: 'Owning Agent.' } } })
const signal = new AbortController().signal
const promise = Promise.resolve({ ok: true as const, value: 'created' })
const create = vi.fn(function (this: object, ...args: unknown[]) {
expect(this).toBe(namespace)
expect(args).toEqual(['agent', undefined, signal])
return promise
})
const namespace = { create }
const remote: object = { goals: namespace }
expect(method.invoke(remote as TypertClientRemote, { agentId: 'agent' }, signal)).toBe(promise)
expect(method.scope).toEqual({ context: 'agent', wire: 'agentId' })
expect(create).toHaveBeenCalledTimes(1)
})
it('requires declared fields even when their parser accepts undefined and permits empty methods', () => {
const unknown = { ...text, create: () => z.unknown(), jsonSchema: {} }
const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/required': definition({ parameters: [{ name: 'value', source: 'json', codec: unknown, strictCodec: unknown }] }),
'goals/empty': definition({ parameters: [] }),
} }, z)
expect(() => info.methods['goals/required']!.input().create().parse({})).toThrow()
const empty = info.methods['goals/empty']!
expect(empty.input().create().parse({})).toEqual({})
expect(() => empty.input().create().parse({ extra: true })).toThrow()
expect(empty.input().jsonSchema?.required).toEqual([])
const call = vi.fn(() => Promise.resolve({ ok: true as const, value: 'empty' }))
const remote: object = { goals: { empty: call } }
void empty.invoke(remote as TypertClientRemote, {}, new AbortController().signal)
expect(call).toHaveBeenCalledWith()
})
it('does not treat source or plain strict descriptors as generated named methods', () => {
const base = {
id: 'pkg#goals/read', namespace: 'goals', method: 'read', service: 'goals',
invocation: { kind: 'direct' as const }, parameters: [], result: { mode: 'src-json' as const },
}
expect(isTypertRemoteMethod(base)).toBe(false)
const strict = { ...base, result: text }
expect(isTypertRemoteMethod(strict)).toBe(false)
const legacy = { ...strict, input: { create: () => z.strictObject({}) } }
expect(isTypertRemoteMethod(legacy)).toBe(false)
const inputOnly = { ...strict, input: () => ({ create: () => z.strictObject({}) }) }
expect(isTypertRemoteMethod(inputOnly)).toBe(false)
const notCallable = { ...inputOnly, invoke: undefined }
expect(isTypertRemoteMethod(notCallable)).toBe(false)
})
it('keeps Context-only descriptors without inventing a named direct caller', () => {
const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/rename': definition({ invocation: { kind: 'context', context: 'agent', wire: 'agentId', codec: text }, implementation: 'renameCurrent' }),
} }, z)
expect(Object.keys(info.methods)).toEqual([])
expect(info.descriptors[0]).toMatchObject({ id: '@fixture/goals#goals/rename', namespace: 'goals', method: 'rename', implementation: 'renameCurrent' })
expect(info.descriptors[0]).not.toHaveProperty('input')
expect(isTypertRemoteMethod(info.descriptors[0]!)).toBe(false)
})
it('returns the exact stream handle and looks up the current mounted method on each call', () => {
const uplink = { codec: text }
const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/watch': definition({ mode: 'stream', parameters: [], uplink }),
} }, z)
const handle = { async *[Symbol.asyncIterator]() { yield 'frame' }, send: vi.fn(), end: vi.fn(), dispose: vi.fn() }
const namespace = { watch: () => handle }
const remote: object = { goals: namespace }
const method = info.methods['goals/watch']!
expect(method.invoke(remote as TypertClientRemote, {})).toBe(handle)
expect(method.uplink).toBe(uplink)
namespace.watch = () => { throw new Error('method withdrawn') }
expect(() => method.invoke(remote as TypertClientRemote, {})).toThrow('method withdrawn')
})
it('retains codecs and documentation when projection is unavailable, without masking parser failures', () => {
const create = vi.fn(() => { throw new Error('invalid schema factory') })
const unsupported = { ...text, create, jsonSchema: undefined, jsonSchemaError: 'Unsupported tool type.' }
const decode = vi.fn((value: unknown) => value)
const encode = vi.fn((value: unknown) => value)
const info = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/create': definition({
parameters: [{ name: 'request', source: 'json', description: 'Still documented.', codec: unsupported, strictCodec: unsupported }],
result: { ...unsupported, description: 'Still a result.', decode, encode },
}),
} }, z)
const method = info.methods['goals/create']!
expect(method.input().jsonSchema).toBeUndefined()
expect(method.input().jsonSchemaError).toBe('Unsupported tool type.')
expect(method.result.jsonSchema).toBeUndefined()
expect(method.result.jsonSchemaError).toBe('Unsupported tool type.')
expect(method.result.description).toBe('Still a result.')
expect(method.parameters[0]?.description).toBe('Still documented.')
expect(method.result.decode).toBe(decode)
expect(method.result.encode).toBe(encode)
expect(create).not.toHaveBeenCalled()
expect(() => method.input().create()).toThrow('invalid schema factory')
expect(() => method.input().create()).toThrow('invalid schema factory')
const unprojected = createTypertRemoteInfo({ package: '@fixture/goals', methods: {
'goals/create': definition({ parameters: [{ name: 'request', source: 'json', codec: { ...text, jsonSchema: undefined }, strictCodec: text }] }),
} }, z).methods['goals/create']!
expect(unprojected.input().jsonSchemaError).toContain('request')
expect(unprojected.input().create().parse({ request: 'valid' })).toEqual({ request: 'valid' })
})
})
+2 -2
View File
@@ -21,8 +21,8 @@
en: 1ec4b304a024bb3c
zh: f17e796c26faabab
/deepseek-ai-dsh-typert-registry/use-this-package/querying-schemas-and-reflection:
en: bc75ee0cfdd77fee
zh: f0fc5bfffe0cc063
en: 254d265a3e5b1638
zh: bfa325ec79afbc72
/deepseek-ai-dsh-typert-registry/use-this-package/registering-a-contribution:
en: 2b2f750a1b145d85
zh: d15c55c01af1755a
+2
View File
@@ -39,6 +39,8 @@ Load the registry plugin; the Client face is installed the same way by the Clien
Consumers read schemas with `get(key)`, `resolve(key)`, or `list(filter?)` and package reflection with `getPackage(name, face?)` or `listPackages(filter?)`. The first schema read materializes one process-realm instance; later reads reuse it. `resolve()` distinguishes a malformed key, an absent package, and a registered package that contributes no schema under that name, each with its own error. `toJSONSchema(key)` projects the materialized Zod schema to JSON Schema without caching the projection.
Invocation information is available through existing `ctx.typert.local.get/list` and `ctx.typert.remotes.get/list` queries. They return the registered descriptor objects, including generated direct methods with `input(options?)`, `result`, and `invoke`; there is no separate metadata store or RPC API. Consumers select `method.input({ strict: true })` for matching strict parsing and JSON Schema without changing registration or Host dispatch. Use `isTypertRemoteMethod(descriptor)` from `@deepseek-ai/dsh-typert-protocol/remote-info` to narrow the base descriptor. This guard exposes inputs as `object` and outputs as `unknown`, not endpoint-dependent generics. The [ordinary generated `TYPERT_REMOTE.methods` index](<../generator/README.md#ordinary-remote-metadata>) supplies precise per-method static types and aliases those same descriptors within its artifact instance.
### Registering a contribution
Generated artifacts register through the [loader](../loader/README.md) in Loader compositions; any other owner calls `ctx.typert.register(contribution)` directly and receives the exact disposer that withdraws it. Duplicate package-face identities, schema keys, invocation ids, or endpoints reject the whole batch before anything is committed.
+2
View File
@@ -39,6 +39,8 @@ kind: "package-reference"
消费方用 `get(key)`、`resolve(key)` 或 `list(filter?)` 读取 schema,用 `getPackage(name, face?)` 或 `listPackages(filter?)` 读取包反射。首次读取会物化一个进程 realm 内的 schema 实例,后续读取复用该实例。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包,各自给出不同的错误。`toJSONSchema(key)` 把已物化的 Zod schema 投影为 JSON Schema,但不缓存该投影。
调用信息可通过现有的 `ctx.typert.local.get/list` 与 `ctx.typert.remotes.get/list` 查询读取。它们返回已注册的描述符对象,包括带有 `input(options?)`、`result` 和 `invoke` 的生成直接方法;不存在独立的元数据存储或 RPC API。消费方选择 `method.input({ strict: true })`,即可取得匹配的严格解析与 JSON Schema,而不改变注册或 Host 分发。使用 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `isTypertRemoteMethod(descriptor)` 收窄基础描述符。该守卫将输入暴露为 `object`、输出暴露为 `unknown`,不提供依赖端点的泛型。[普通生成产物的 `TYPERT_REMOTE.methods` 索引](<../generator/README.zh.md#ordinary-remote-metadata>)提供精确的各方法静态类型,并在该产物实例内引用同一批描述符。
### 注册贡献
生成产物在 Loader 组合中通过 [loader](../loader/README.zh.md) 注册;其他所有者直接调用 `ctx.typert.register(contribution)`,并获得撤销它的同一资源释放函数。重复的包与 face 组合键、schema 键、调用 id 或端点会在提交任何内容之前拒绝整个批次。
@@ -13,6 +13,7 @@ import type {
TypertLookup,
TypertRemoteContribution,
} from '@deepseek-ai/dsh-typert-protocol'
import { createTypertRemoteInfo, isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info'
import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
@@ -99,6 +100,45 @@ function scopedInvocation(): InvocationDescriptor {
}
describe('TypertRegistry', () => {
it('indexes the same generated method objects and withdraws them with their owner', async () => {
const ctx = await makeCtx()
const codec = { mode: 'strict' as const, typeSymbol: '@fixture/types#Text', create: () => z.string(), jsonSchema: { type: 'string' } }
const info = createTypertRemoteInfo({ package: '@fixture/remote', methods: {
'goals/create': {
service: 'goals', invocation: { kind: 'direct' }, description: 'Create a goal.',
parameters: [{ name: 'request', source: 'json', codec, strictCodec: codec }], result: codec,
},
} }, z)
const descriptor = info.methods['goals/create']!
expect(info.descriptors[0]).toBe(descriptor)
try {
const owner = ctx.plugin({
inject: ['typert'],
apply(ctx) {
ctx.typert.register({ ...toolsContribution(), invocations: info.descriptors })
ctx.typert.remotes.register(info)
},
})
await owner
for (const registry of [ctx.typert.local, ctx.typert.remotes]) {
const method = registry.get('goals/create')
expect(method).toBe(descriptor)
expect(registry.list()[0]).toBe(descriptor)
if (method === undefined || !isTypertRemoteMethod(method)) throw new Error('Generated method is missing')
expect(method.input().create().parse({ request: 'goal' })).toEqual({ request: 'goal' })
expect(method.result).toBe(codec)
}
await owner.dispose()
for (const registry of [ctx.typert.local, ctx.typert.remotes]) {
expect(registry.get('goals/create')).toBeUndefined()
expect(registry.list()).toEqual([])
}
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('retains generated result decoders until the contribution is disposed', async () => {
const ctx = await makeCtx()
const decode = (value: unknown) => value
+12
View File
@@ -14340,6 +14340,9 @@ importers:
'@deepseek-ai/dsh-tool-cordis':
specifier: workspace:*
version: link:../../extensions/tool-cordis
'@deepseek-ai/dsh-tools':
specifier: workspace:*
version: link:../../core/tools
'@deepseek-ai/dsh-typert-registry':
specifier: workspace:*
version: link:../registry
@@ -14362,9 +14365,15 @@ importers:
'@deepseek-ai/dsh-app-boot':
specifier: workspace:*
version: link:../../boot/app-boot
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../protocol
'@deepseek-ai/dsh-typert-registry':
specifier: workspace:*
version: link:../registry
typescript:
specifier: ^6.0.3
version: 6.0.3
zod:
specifier: 4.4.3
version: 4.4.3
@@ -14374,6 +14383,9 @@ importers:
'@deepseek-ai/dsh-brand':
specifier: workspace:*
version: link:../../util/brand
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
-3
View File
@@ -2054,9 +2054,6 @@
"2f2c5a76501f35183d9708040f1f79cc30c8c9da5278823aa19ece7af67f5812": 1,
"ec229b2914993fe3b9cabeffe88aaca92dac0aed964d27a4704cd64202044127": 1
},
"packages/typert/protocol/src/index.ts": {
"35e98b658709d2dead42b6fc80150e0cac9075c55ea512cae80951983bd1f69c": 1
},
"packages/typert/protocol/tests/protocol.spec.ts": {
"418fc1acbd1aac8b6b9ba1ac25f30a4fc33d632e372085c73f396e76b9da883a": 1,
"539af7158f6057075289d3b1543bd5b692887755287d81f91d9b9c7cc14cf9cb": 1,
+1
View File
@@ -125,6 +125,7 @@
"@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"],
"@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"],
"@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"],
"@deepseek-ai/dsh-typert-protocol/remote-info": ["./packages/typert/protocol/src/remote-info.ts"],
"@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"],
"@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"],
"@deepseek-ai/dsh-typert-generator": ["./packages/typert/generator/src/index.ts"],