diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 16ecfcc400..a3dae54b9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: 09631349226837d7f93112fe5e9c517f2fee099c -2026-07-16-explicit-turn-cancellation.zh.md: baf039d49578895d53ffae4c6b88d29b3d1563b6 +2026-07-16-explicit-turn-cancellation.md: d59accf2dc3f894ffab226e5ddc9d2e746f4d93e +2026-07-16-explicit-turn-cancellation.zh.md: 8fb0728eb2bbe8fa01dbefebe7e02aa8c836617a diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 0963134922..d59accf2dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -12,7 +12,7 @@ The [initiating Agent scope decision](2026-07-15-agent-initiator-scope.md) inten ## Decision -Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active `TurnCancellation` copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work. +Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' } | { kind: 'hook'; reason: string } | { kind: 'disposed' }`, and `agent.cancel()` requires one. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. The holder aborts with the caller's own object rather than a detached copy, so a transport reaching that signal can extend it: Node's fetch assigns a `stack` onto the reason it receives. The loop therefore copies the declared fields where the cause becomes durable data. Freezing the cause at `cancel()` is not an alternative — undici performs that assignment from strict-mode code, so a frozen reason makes `fetch` reject with `TypeError: Cannot add property stack` in place of the abort reason. Idle cancellation has no holder to mutate and does not arm later work. An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail. @@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](../../archived/architecture/2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's provider-managed termination and join boundary; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the supported local scope, Job, and fallback mechanics. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. -`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. +`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a typed cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol. @@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after ## Verification -Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. +Contract tests verify the typed caller union, field copying where the cause becomes durable data, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index baf039d495..8fb0728eb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' } | { kind: 'hook'; reason: string } | { kind: 'disposed' }`,`agent.cancel()` 必须显式传入其一。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。持有者中止时携带的是调用方自己的那个对象,而非一份分离的拷贝,因此能接触到该 signal 的传输层可以向其添加属性:Node 的 fetch 会给它收到的 reason 赋一个 `stack`。所以循环在 cause 成为持久数据的那一步拷贝已声明字段。在 `cancel()` 处冻结 cause 并非可选方案——undici 是在严格模式代码中执行该赋值的,被冻结的 reason 会让 `fetch` 以 `TypeError: Cannot add property stack` 取代中止原因而拒绝。空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。会话 seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 @@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](../../archived/architecture/2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器由提供方管理的终止与等待边界;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责受支持本地路径上的 scope、Job 与 fallback 机制。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带类型化的 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 @@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 ## 验证 -约定测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 +约定测试验证类型化调用方联合类型、在 cause 成为持久数据处拷贝字段、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 190fecf123..279482f67a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b599b3f9a353738bbb44a4da9b5a72be8309e2fb -2026-08-02-typert-remote-method-calls.zh.md: 64dc84f0ae07b10d50452ef7e36c30db102953cf +2026-08-02-typert-remote-method-calls.md: 2a12aaf78f638d8af786d2ca817a3fee07a2c614 +2026-08-02-typert-remote-method-calls.zh.md: bf4a7b0179b4dc878a0a53c7076c02e6f1bf0273 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b599b3f9a3..2a12aaf78f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -31,8 +31,8 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | `@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 | | 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, and invokes methods | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | +| 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 | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, physical serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | | API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -181,7 +181,7 @@ Consequently, `SessionId`, the Agent wire ID, the request, and the result all re Remote methods themselves use declaration-map navigation. Typert anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and successful Host results instead of executing invocation codecs. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and JSON success results. Binary success results use their generated codec after multipart decoding, as specified by [binary Remote transfer](2026-09-17-workspace-file-binary-transfer.md). If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by Typert need no additional names. @@ -514,7 +514,7 @@ Canonical public types require business DTOs to have type-only entries, which ma Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. -Generated Host and Client artifacts carry matching Zod factories, but Client Remote does not materialize invocation schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms. +Generated Host and Client artifacts carry matching Zod factories; Client Remote materializes only binary result schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 64dc84f0ae..bf4a7b0179 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -31,8 +31,8 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、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 并调用方法 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | +| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、物理序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -181,7 +181,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Remote 方法本身使用 declaration map 导航。Typert 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与 identity codec 校验输入;Client Remote 信任生成的 TypeScript 参数与成功的 Host 结果,不执行调用 codec。复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与身份 codec 校验输入。Client Remote 信任生成的 TypeScript 参数与 JSON 成功结果。二进制成功结果在 multipart 解码后执行生成的结果 codec,具体由[二进制 Remote 传输](2026-09-17-workspace-file-binary-transfer.zh.md)规定。复杂类型若无法生成严格 codec,LIB 构建失败,不降级为 `unknown` 或未校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 Typert 明确支持的简单组合不需要额外命名。 @@ -514,7 +514,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的「Remote 未挂载」错误失败。 -生成的 Host 与 Client 产物携带匹配的 Zod factory,但 Client Remote 不实例化调用 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。 +生成的 Host 与 Client 产物携带匹配的 Zod factory;Client Remote 仅实例化二进制结果 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示「该协议能力已被消费端选择」,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 050ce7d48f..f44cffc1fa 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563 -2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715 +2026-09-05-workspace-files-service.md: 65bebf18175d9a94452c92cd36ada3c5ef267ce5 +2026-09-05-workspace-files-service.zh.md: b4ca420042dc702f9bc1eaf5bb09f2667a19322d diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 40157508a8..65bebf1817 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -25,15 +25,13 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's `api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. -### The `workspaceFiles` Remote namespace +### The `workspaceFiles` file operations -Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them: +Every Host method takes `WorkspaceFileScope` first. The `workspaceFileScope` Gateway lookup resolves Session identity. It reads the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The five signatures, as `src/index.ts` declares them: ```ts ignore-check @Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise @Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable @@ -41,20 +39,20 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. -- **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. -- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. +- **`readBytes`** returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under `maxFileBytes` when `range` is absent, or a bounded window under `maxBytes` when present. `range: {}` uses offset zero and the configured window cap; windows can read files above the complete-file cap. The [binary transfer decision](2026-09-17-workspace-file-binary-transfer.md) owns the Remote response encoding. +- **`baseFile`** selects target resolution independently of `range`: the target `path` must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and address semantics. - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire -Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. +Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd; `readBytes` with `baseFile` resolves the target from that file's directory. `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end. ### File checks and workspace containment -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: +`read`, `readBytes`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. 2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. @@ -73,7 +71,7 @@ Each failure is one `RemoteError` code with typed details, declared beside the t | `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` | | `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` | -| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` | | `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` | @@ -109,7 +107,7 @@ The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` ### Related notes -The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. +The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. ## Alternatives considered @@ -117,7 +115,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, **A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries. -**Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises. +**Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe. **Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. @@ -138,14 +136,14 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version. - File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README. - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action. -- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats. +- Office authorization probes use `readBytes` with a one-byte `range`; Document Preview uses the same method without `range` for complete-file formats. ## Testing -Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred - A bound on a `changes` generation's queue. -- The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only. +- Write and search operations; the service is read-only. - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 51de0ad5b6..b4ca420042 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -25,15 +25,13 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 -### `workspaceFiles` Remote 命名空间 +### `workspaceFiles` 文件操作 -每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明: +每个 Host 方法首参都是 `WorkspaceFileScope`。`workspaceFileScope` Gateway lookup 解析 Session 身份。它优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。五个签名照 `src/index.ts` 的声明: ```ts ignore-check @Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise @Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable @@ -41,20 +39,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 -- **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 -- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 +- **`readBytes`** 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 `range` 时,在 `maxFileBytes` 上限内完整读取;传入时,在 `maxBytes` 上限内读取有界窗口。`range: {}` 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。[二进制传输决议](2026-09-17-workspace-file-binary-transfer.zh.md)负责 Remote 响应编码。 +- **`baseFile`** 独立于 `range` 选择目标解析方式:目标 `path` 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载与地址语义。 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 -离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 +离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd;带 `baseFile` 的 `readBytes` 从基准文件所在目录解析目标。 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。 ### 文件检查与工作区包含 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: +`read`、`readBytes` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 @@ -73,7 +71,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 | `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` | | `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` | -| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` | | `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` | @@ -109,7 +107,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ### 相关记录 -[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 +[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 ## Alternatives considered @@ -117,7 +115,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 **带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。 -**经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。 +**提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。 **文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。 @@ -138,14 +136,14 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。 - 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。 -- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。 +- Office 授权探测使用带单字节 `range` 的 `readBytes`;Document Preview 对整文件格式使用同一方法并省略 `range`。 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred - 给 `changes` 一代流的队列加上限。 -- `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。 +- 写入和搜索操作;本服务只读。 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 414fda0806..ecb554c14d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2 -2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a +2026-09-08-document-preview-operations.md: 0b90c17ba94d0c7cd30afb0a6e6a033f8f12d0df +2026-09-08-document-preview-operations.zh.md: ec9c208c97dfe75afdb0915c655d9c1c7b4d42ff diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index be3a835a62..0b90c17ba9 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -16,9 +16,9 @@ Document Preview separates resource observation from content reads. The [resourc Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls `ctx.remote.workspaceFiles.read` for text and `ctx.remote.workspaceFiles.readBytes` for native bytes, with `baseFile` for related resources. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport uses the [binary workspace file transfer](2026-09-17-workspace-file-binary-transfer.md). Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. @@ -28,7 +28,7 @@ PDF.js's official TextLayerBuilder owns selection boundaries and copy normalizat **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. -**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. +**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session addressing and byte transport out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. **UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index efb87e16cc..ec9c208c97 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -16,9 +16,9 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 通过 `ctx.remote.workspaceFiles.read` 读取文本,通过 `ctx.remote.workspaceFiles.readBytes` 读取原生字节,关联资源使用 `baseFile`。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输使用[工作区文件二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 @@ -28,7 +28,7 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 -**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 +**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需处理 Session 寻址和字节传输。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 **把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml index 97cdb07d3f..eed28e3666 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md -2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c -2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7 +2026-09-09-workspace-file-read-authority.md: 140816e8c48a6628133cf9208d5b48545e2e5eaf +2026-09-09-workspace-file-read-authority.zh.md: 75ffee32c40930c459018ab0fde42d5324371ba8 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md index d1a5b9d963..140816e8c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md @@ -10,11 +10,11 @@ Workspace Files serves both file content and workspace navigation. Applying work ## Decision -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. +`read`, `readBytes`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. `list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate. -`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. +`readBytes` with `baseFile` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox. diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md index f8b0fae432..75ffee32c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md @@ -10,11 +10,11 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 ## Decision -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 +`read`、`readBytes` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 `list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。 -`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 +带 `baseFile` 的 `readBytes` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 9947e91ff2..b40fa370ec 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: b8e94bf6b392edf7931346400fda9ca5d3416188 -2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d +2026-09-11-node-office-kit.md: 132ac8b3dd709f9f16254a5569dec59f01d29fda +2026-09-11-node-office-kit.zh.md: c7bdab2322766c42cfee4092f158d7b6b530e875 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index b8e94bf6b3..132ac8b3dd 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -24,7 +24,7 @@ DSH exports raster images at configurable resolution, defaulting to 192 DPI for The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. -Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. +Office responses use `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 41b84f26c8..c7bdab2322 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -24,7 +24,7 @@ DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF [Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 -普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 +Office 响应使用 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 [kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml new file mode 100644 index 0000000000..7eef4b0fa9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md +2026-09-17-workspace-file-binary-transfer.md: b419c8bd15f51246825ceddf2905ec055bbc70ae +2026-09-17-workspace-file-binary-transfer.zh.md: 9cf3d7074c3d536a5c8a39732231c6996d6f6ead diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md new file mode 100644 index 0000000000..b419c8bd15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md @@ -0,0 +1,31 @@ +# Agent Note: Binary workspace file transfer + +Status: implemented + +English | [中文](2026-09-17-workspace-file-binary-transfer.zh.md) + +## Problem + +Document previews need native bytes. Encoding file contents as base64 adds about one third to the payload before compression and requires browser decoding. A separate file Fetch route also duplicates the Gateway's method dispatch, Session lookup, and error handling. + +## Decision + +The [Remote method architecture](2026-08-02-typert-remote-method-calls.md) owns method dispatch and lifetime; this note defines binary success encoding and Client validation. The [workspace file service](2026-09-05-workspace-files-service.md) exposes one binary `readBytes` Remote. Its required options object independently selects a byte `range` and a `baseFile` for relative target resolution. Without `range`, it reads a complete file. This avoids separate method names for orthogonal choices while preserving bounded `fs.readBytes` and `fs.readByteRange` reads. + +Typert recursively recognizes `Uint8Array` in unary result types, including root values, optional fields, and containers. Generated result codecs provide `encode()` for subtrees whose types can contain bytes and `decode()` to validate the reconstructed value without iterating, copying, or freezing byte payloads; Client declarations narrow each byte buffer to `ArrayBuffer`. Gateway executes the generated encoder, keeps strict JSON values unchanged, and performs runtime byte detection for source-mode calls. It returns JSON-compatible metadata and result-relative byte attachments to Connection. Connection owns standard `FormData` parts and preserves the RPC envelope and correlation id in JSON metadata; it does not inspect business values or depend on Typert reflection. An attachment table associates byte parts with paths through the JSON result; field names remain business-owned. The Client restores `Uint8Array` views over `Blob.arrayBuffer()` and Gateway delegates validation to the generated decoder. Ordinary results and errors retain JSON responses. Gateway owns method dispatch, Session lookup, cancellation, result projection, and mount lifetime; feature code needs no multipart reader or dedicated route. + +## Alternatives considered + +- Separate `readAll`, range, and related-file methods mix independent path and extent choices. One options object permits their combination without adding another method. +- A workspace-only Fetch route keeps binary transport out of Remote but requires feature-specific dispatch, Session lookup, and error decoding. +- A whole-result binary marker or a reserved `data` field couples transport to one business result. Type-directed codecs and attachment paths support independently named and nested fields without adding result-wide categories. +- A custom metadata prefix needs owned framing and length validation. FormData delegates those mechanics to the platform, at the cost of Blob copies. +- Metadata HTTP headers constrain long Unicode paths. Multipart fields keep paths in the body; HTML stays in an isolated Blob iframe rather than a navigable Host response. + +## Consequences + +Byte ranges retain only the requested bytes under `maxBytes`, independently of `maxFileBytes`. Complete reads retain a bounded whole file; this is not a streaming preview or zero-copy optimization. On HTTP disconnect, the bridge aborts the request signal and drains materialized response chunks without writing to the socket: cancelling Node multipart bodies can race their producer and cause an unhandled `ERR_INVALID_STATE` rejection. Office authorization probes use the same Remote with a one-byte range; converted PDFs remain base64 in the separate Office Remote and use its bounded decoder. Plugin callers migrate to `readBytes` options and stop decoding its data as base64. Binary arguments, events, and stream items remain unsupported; recursive types require acyclic runtime values. No Session event or persistence format changes. + +## Validation + +Workspace tests cover complete and ranged reads, combined base-file resolution, limits, Session lookup, cancellation, and binary RPC round trips. Connection and Gateway tests cover nested and multiple attachments, framing, correlation, malformed multipart, and cancelled or withdrawn calls; Typert tests cover recursive result codecs, Client declarations, and rejected binary inputs or streams. The built browser scenario checks raw PNG bytes plus HTML, PDF, image, and text previews. Local benchmarks compare full and ranged reads using identical synthetic files, and distinguish transport/decoding from the complete read path. diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md new file mode 100644 index 0000000000..9cf3d7074c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 工作区文件二进制传输 + +Status: implemented + +[English](2026-09-17-workspace-file-binary-transfer.md) | 中文 + +## 问题 + +文档预览需要原生字节。将文件内容编码为 base64 会在压缩前使载荷增加约三分之一,并要求浏览器解码。独立的文件 Fetch 路由还会重复 Gateway 的方法分发、Session 查找和错误处理。 + +## 决策 + +[Remote 方法架构](2026-08-02-typert-remote-method-calls.zh.md)负责方法分发与生命周期;本记录规定二进制成功响应的编码及 Client 校验。[工作区文件服务](2026-09-05-workspace-files-service.zh.md)提供单一的二进制 `readBytes` Remote。其必填选项对象独立选择字节 `range` 和用于相对目标解析的 `baseFile`。未传 `range` 时读取完整文件。这避免为正交的选择设置不同方法名,同时保留有界的 `fs.readBytes` 和 `fs.readByteRange` 读取。 + +Typert 递归识别一元结果类型中的 `Uint8Array`,包括根值、可选字段和容器。生成的结果 codec 提供 `encode()` 处理类型可能包含字节的子树,并通过 `decode()` 校验还原后的值,不逐字节遍历、复制或冻结字节载荷;Client 声明将每个字节缓冲区限定为 `ArrayBuffer`。Gateway 执行生成的 encoder,保持 strict JSON 值不变,并为源码模式调用执行运行时字节识别;随后向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件。Connection 负责标准 `FormData` 部分,并在 JSON 元数据中保留 RPC 信封和关联 id;它不检查业务值,也不依赖 Typert 反射。附件表将字节部分与 JSON 结果中的路径关联,字段名仍由业务拥有。Client 恢复基于 `Blob.arrayBuffer()` 的 `Uint8Array` 视图,Gateway 将校验交给生成的解码器。普通结果与错误继续使用 JSON 响应。Gateway 负责方法分发、Session 查找、取消、结果投影及挂载生命周期;功能代码无需 multipart 读取器或专用路由。 + +## 考虑过的替代方案 + +- 分立的 `readAll`、范围和关联文件方法混合了路径与读取范围两个独立选择。单一选项对象支持组合,无需再增加方法。 +- 工作区专用 Fetch 路由让二进制传输独立于 Remote,但需要功能专属的方法分发、Session 查找与错误解码。 +- 整体结果的二进制标记或保留的 `data` 字段会将传输与某一种业务结果耦合。按类型生成的 codec 和附件路径支持独立命名及嵌套字段,无需新增整体结果分类。 +- 自定义元数据前缀需要自有的帧格式与长度校验。FormData 将这些机制交给平台,代价是 Blob 复制。 +- 元数据 HTTP 头会限制较长的 Unicode 路径。Multipart 字段将路径保留在正文中;HTML 仍置于隔离的 Blob iframe,不作为可导航的 Host 响应。 + +## 影响 + +字节范围仅保留 `maxBytes` 内的请求字节,不受 `maxFileBytes` 约束。完整读取保留有上限的整个文件;这不是流式预览或零复制优化。HTTP 连接断开后,桥接器中止请求信号,并排空已准备的响应块,不再写入 socket:取消 Node multipart 响应体可能与其生产方竞争,导致未处理的 `ERR_INVALID_STATE` 拒绝。Office 授权探测使用同一 Remote 的单字节范围;转换后的 PDF 仍通过独立的 Office Remote 传输 base64,并使用其有界解码器。插件调用方迁移到 `readBytes` 选项,并停止将其 data 按 base64 解码。仍不支持二进制参数、事件和流条目;递归类型要求运行时值无环。不改变 Session 事件或持久化格式。 + +## 验证 + +Workspace 测试覆盖完整与分段读取、基准文件解析组合、上限、Session 查找、取消以及二进制 RPC 往返。Connection 和 Gateway 测试覆盖嵌套及多附件、帧格式、关联关系、畸形 multipart,以及取消或撤销的调用;Typert 测试覆盖递归结果 codec、Client 声明,以及对二进制输入和流的拒绝。构建后的浏览器场景验证原始 PNG 字节以及 HTML、PDF、图片和文本预览。本机 benchmark 使用相同合成文件比较完整与分段读取,并区分传输/解码和完整读取路径。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 702e3bf1fc..0d328978e1 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554 +2026-09-05-sidebar-text-preview-and-file-tree.md: e978043545b7f172f2b67053fe44b5936ea9b529 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 088d4a58c973843ac5085cfaa1ca9980387bcc8f diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 8ddb1a3363..e978043545 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -34,7 +34,7 @@ The [Document Preview decision](../architecture/2026-09-08-document-preview-oper A tab uses `dsh-resource://file/session//`, where the path may be relative or absolute ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers). `hostFileOf` accepts only this Session scope and takes both Session and path from the address; a Session-less `absolute` address is not claimed. A malformed claimed address throws as a programming error. -Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readAll`. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. +Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readBytes` with `{}` options. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index 294a2b4560..088d4a58c9 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -34,7 +34,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 tab 使用 `dsh-resource://file/session//`,其中路径可以是相对路径或绝对路径([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md)负责该语法与 `fileAddressFor` / `parseFileAddress` 辅助函数)。`hostFileOf` 只接受这种 Session scope,并从地址取得 Session 与路径;不认领不带 Session 的 `absolute` 地址。被认领的地址若格式错误,则作为程序错误抛出。 -元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过 `readAll` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 +元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过带 `{}` 选项的 `readBytes` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml index 70ee32d07a..bee0b26a39 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md -2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2 -2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa +2026-09-17-sidebar-file-and-directory-auto-refresh.md: f509a8cf2642d3ca816d40e9e9817d3c962c79a6 +2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: ff7a950aa86c663d4f5d2fb8e20d64bb2b077f6b diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index f33be6a8d4..f509a8cf26 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -38,11 +38,11 @@ Notifications invoke existing reload/list operations; a failed reload does not r | Class or type | File | Responsibility | |---|---|---| -| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code | +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. The base implementation rejects `FS_IO_ERROR`, so a provider without watching states nothing and adds no FS error code | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` | | `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks | -| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | Inherits the base `FS_IO_ERROR` rejection; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch | | `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure | diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md index 88fe6e7abb..ff7a950aa8 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -38,11 +38,11 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | 类或类型 | 所在文件 | 职责 | |---|---|---| -| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 | +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,基类以 `FS_IO_ERROR` 拒绝,不支持监听的提供方无需覆写,也不新增 FS 错误码 | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` | | `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 | -| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | 继承基类的 `FS_IO_ERROR` 拒绝;Host 将其转换为 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 | | `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 | diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 7aac156cd8..4e0fc43235 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -28,6 +28,7 @@ import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs' import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' +import * as CordisInspectProviders from '@deepseek-ai/dsh-tool-cordis/host' /** * With-key PTC mode proof: a real model receives only `run_code`, composes two @@ -280,6 +281,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { it('uses runtime inspection results directly through PTC', async () => { ctx = await typedPtcModeHarness() await ctx.plugin(CordisHostRunner) + await ctx.plugin(CordisInspectProviders) await ctx.plugin(ToolCordis) const agent = { id: SessionId('ptc-cordis'), diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 692cad3ebd..0434078f16 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' @@ -369,6 +369,23 @@ describe('the shipped Web composition', () => { expect(tools).not.toContain('str_replace_editor') expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() + // The persona is `standard`'s, pinned verbatim so the two composition + // files cannot drift apart: tool descriptions and the skill catalog + // carry every creation-mode instruction. + const standard = await ctx.agents.create({ + sessionId: SessionId('preset-cordis-standard-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + const persona = async (agent: Agent) => (await ctx.systemPrompt.assemble({ scope: agent })).sections + .filter(section => section.name.startsWith('deployment:persona-')) + const cordisPersona = await persona(handle.agent) + expect(cordisPersona.map(section => section.name)).toEqual(['deployment:persona-prefix', 'deployment:persona-suffix']) + expect(cordisPersona).toEqual(await persona(standard.agent)) + } finally { + await standard.dispose() + } + // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) @@ -651,6 +668,80 @@ describe('product Bundle and user-preset intersection', () => { }, 120_000) }) +describe('a user preset copied from a shipped one', () => { + /** The JSON text a `cordis_inspect_*` result renders. */ + function resultText(result: { content: ReadonlyArray<{ type: string; text?: string }> }): string { + return result.content.filter(part => part.type === 'text').map(part => part.text ?? '').join('') + } + + it('mounts beside the shipped `cordis` preset and reads the shared Host inspect providers', async () => { + // The Host inspect providers are one process-global set registered by the + // host composition (`@deepseek-ai/dsh-tool-cordis/host`); each preset's + // `tool-cordis` row only registers the tools. Before that split the copy + // failed to mount: its row re-registered provider "Service". + const root = await mkdtemp(join(tmpdir(), 'dsh-copied-preset-')) + const userRoot = join(root, 'presets') + await cp(join(SHIPPED_PRESET_ROOT, 'cordis'), join(userRoot, 'cordis-copy'), { recursive: true }) + const settingsFile = join(root, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + const copyCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false }, + }]) + try { + const shipped = await copyCtx.agents.create({ + sessionId: SessionId(`preset-cordis-shipped-${randomUUID()}`), + setup: agentCtx => copyCtx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined), + }) + const copied = await copyCtx.agents.create({ + sessionId: SessionId(`preset-cordis-copy-${randomUUID()}`), + setup: agentCtx => copyCtx.agentPresets.mount(agentCtx, 'cordis-copy').then(() => undefined), + }) + try { + for (const handle of [shipped, copied]) { + expect(toolNames(copyCtx, handle.agent)).toEqual(expect.arrayContaining([ + 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager', + ])) + } + const signal = new AbortController().signal + const listed = await copyCtx.tools.execute({ + callId: ToolCallId('copied-preset-inspect-list'), + name: 'cordis_inspect_list', + arguments: {}, + signal, + agent: copied.agent, + }) + expect(listed.isError).toBe(false) + const providers = (JSON.parse(resultText(listed)) as { providers: Array<{ id: string; platform: string }> }).providers + expect(providers.filter(provider => provider.platform === 'host').map(provider => provider.id)) + .toEqual(['Service', 'Event', 'Builtin', 'Tool']) + + // The `Tool` provider is the one built over the host context: it + // answers with the shared registry's view of the REQUESTING agent, + // so the copy sees its own preset's tools, not the host's empty set. + const queried = await copyCtx.tools.execute({ + callId: ToolCallId('copied-preset-inspect-tools'), + name: 'cordis_inspect_query', + arguments: { platform: 'host', provider: 'Tool', method: 'listTools' }, + signal, + agent: copied.agent, + }) + expect(queried.isError).toBe(false) + const tools = (JSON.parse(resultText(queried)) as { data: { tools: Array<{ name: string }> } }).data.tools + expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([ + 'bash', 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager', + ])) + } finally { + await copied.dispose() + await shipped.dispose() + } + } finally { + await copyCtx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }, 120_000) +}) + describe('a switch survives the session', () => { it('records the choice so the log states what the agent runs', async () => { const handle = await ctx.agents.create({ diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3e6b8c959e..81f1df65f7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: cc7f59fdd1706a99c31a85b6ac44d11c3b15a251 -README.zh.md: ae8ef98d07c3318e6cbc9440eb6820e428facb36 +README.md: 43cde4f41f2e82bf2d3ccce0a7e93d81234010c5 +README.zh.md: 9e300996cd7ed0428c7199e9f9bd43b6f9077e0a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cc7f59fdd1..43cde4f41f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -48,7 +48,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, native directory selection, and the lease-scoped Browser bridge only to `dsh-app://app` documents. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index ae8ef98d07..9e300996cd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -48,7 +48,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b79df71ff2..15d7453dcb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -110,6 +110,17 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } +/** + * Opaque chrome fallback matching the built-in sidebar palette (the resolved + * `--dsw-static-neutral-bluish-900` / `-50` tokens). An approximation for + * custom themes: Windows swaps in the renderer's measured palette over the + * windowsAppearance IPC, and macOS shows it only while minimized or hidden. + * @returns the sidebar fill hex for the active system color scheme. + */ +function chromeFallbackFill(): string { + return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb' +} + function createWindow(preload: string, show = false, primary = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, @@ -119,7 +130,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi show, ...(process.platform === 'win32' && primary ? { titleBarStyle: 'hidden' as const, - titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb', + titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: chromeFallbackFill(), symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' }, } : {}), // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy @@ -157,6 +168,26 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi // Reloads and navigations re-register the preload listener; resend the // current state so a fullscreen reload does not fall back to windowed CSS. window.webContents.on('did-finish-load', sendFullscreen) + // Deminiaturize reattaches the NSVisualEffectView material late + // (electron/electron#25368), so a transparent window shows the desktop + // through the sidebar until then. Paint an opaque base while minimized or + // hidden so the deminiaturize animation and the reattachment gap show a + // solid fill; restoring flips back, and the null -> 'sidebar' transition + // forces the material to reattach. + const applyBackdrop = (): void => { + if (window.isDestroyed()) return + if (window.isMinimized() || !window.isVisible()) { + window.setVibrancy(null) + window.setBackgroundColor(chromeFallbackFill()) + } else { + window.setVibrancy('sidebar') + window.setBackgroundColor('#00000000') + } + } + window.on('minimize', applyBackdrop) + window.on('hide', applyBackdrop) + window.on('restore', applyBackdrop) + window.on('show', applyBackdrop) } window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => { const items: MenuItemConstructorOptions[] = [] diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 7d880a857c..8133c72a7c 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,6 +1,6 @@ -/** Origin-scoped boot, native directory selection, and update presentation with native confirmation actions. */ +/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' @@ -30,6 +30,12 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) + // The composer cites dropped, picked, and pasted files and folders that + // have a real path as `@path` references instead of uploading them; a + // File without one (pasted bytes) answers '' and uploads as before. + contextBridge.exposeInMainWorld('__DSH_HOST_PATHS__', { + pathFor: (file: File) => webUtils.getPathForFile(file), + }) contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 9be17e5321..329cf5a9b8 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -72,13 +72,18 @@ const harness = await vi.hoisted(async () => { readonly restore = vi.fn() readonly setSize = vi.fn() readonly setTitleBarOverlay = vi.fn() + readonly setVibrancy = vi.fn() + readonly setBackgroundColor = vi.fn() constructor(readonly options: { show: boolean; modal?: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } isDestroyed() { return this.destroyed } fullscreen = false isFullScreen() { return this.fullscreen } - isMinimized() { return false } + minimized = false + isMinimized() { return this.minimized } + visible = true + isVisible() { return this.visible } isFocused() { return true } async loadURL(url: string) { this.urls.push(url) @@ -136,9 +141,10 @@ const harness = await vi.hoisted(async () => { } }), }) + const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false } return { failWindow(error: Error) { windowFailure = error }, - windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, + windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), get updateState() { return updateState }, @@ -179,6 +185,7 @@ const harness = await vi.hoisted(async () => { updateCheck.mockReset().mockImplementation(async () => updateState) updateDownload.mockReset().mockImplementation(async () => updateState) updateInstall.mockReset().mockImplementation(async () => updateState) + nativeTheme.themeSource = 'system'; nativeTheme.shouldUseDarkColors = false preparing = deferred(); prepared = deferred(); hostStarted = deferred() navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred() policyBlocked = deferred() @@ -201,7 +208,7 @@ vi.mock('electron', () => ({ BrowserWindow: harness.FakeWindow, dialog: harness.dialog, shell: { openExternal: harness.openExternal }, - nativeTheme: { themeSource: 'system' }, + nativeTheme: harness.nativeTheme, ipcMain: { on: harness.ipcOn, handle: (channel: string, handler: InvokeHandler) => { @@ -591,6 +598,48 @@ describe('desktop main startup', () => { expect(window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)).toHaveLength(0) }) + it('covers the macOS vibrancy reattach gap with an opaque base while minimized or hidden', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + // Minimizing drops the vibrancy material and paints the theme's opaque fill. + window.minimized = true + window.emit('minimize') + expect(window.setVibrancy).toHaveBeenLastCalledWith(null) + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#f9fafb') + // Restoring re-requests the material and returns to the transparent base. + window.minimized = false + window.emit('restore') + expect(window.setVibrancy).toHaveBeenLastCalledWith('sidebar') + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#00000000') + // Hiding under the dark palette picks the dark opaque fill. + harness.nativeTheme.shouldUseDarkColors = true + window.visible = false + window.emit('hide') + expect(window.setVibrancy).toHaveBeenLastCalledWith(null) + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#1b1b1c') + window.visible = true + window.emit('show') + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#00000000') + // A destroyed window ends the backdrop updates. + const applied = window.setVibrancy.mock.calls.length + window.destroyed = true + window.emit('minimize') + expect(window.setVibrancy.mock.calls).toHaveLength(applied) + }) + + it.each(['win32', 'linux'] as const)('registers no backdrop swap on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.minimized = true + window.emit('minimize') + expect(window.setVibrancy).not.toHaveBeenCalled() + expect(window.setBackgroundColor).not.toHaveBeenCalled() + }) + it('follows the Windows primary document language and palette without trusting other frames', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') await import('../src/main.ts') diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index f1652d89ea..cdce12e6b4 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -6,6 +6,7 @@ import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' const electron = vi.hoisted(() => ({ contextBridge: { exposeInMainWorld: vi.fn() }, ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn(), send: vi.fn() }, + webUtils: { getPathForFile: vi.fn() }, })) vi.mock('electron', () => electron) vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) @@ -73,6 +74,23 @@ it('exposes a directory picker only to the local application document', async () } }) +it('reports host paths of picked files only to the local application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_HOST_PATHS__')?.[1] as { pathFor(file: File): string } + const picked = new File(['x'], 'notes.md') + electron.webUtils.getPathForFile.mockReturnValue('/Users/me/notes.md') + expect(api.pathFor(picked)).toBe('/Users/me/notes.md') + expect(electron.webUtils.getPathForFile).toHaveBeenCalledExactlyOnceWith(picked) + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_HOST_PATHS__')).toBe(false) + } +}) + it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])( 'installs Windows appearance only for the application document (%s)', async (url) => { vi.stubGlobal('location', new URL(url)) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 603569a567..adefac10ee 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -571,7 +571,21 @@ else process.exit(1); await successShot(page, 'pdf-drag-selection') sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent') + const pngResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/workspaceFiles/readBytes' + && (response.request().postDataJSON() as { payload: { args: { path: string } } }).payload.args.path === 'tiny.png') await openFile('tiny.png') + const transferred = await pngResponse + expect(transferred.headers()['content-type']).toMatch(/^multipart\/form-data;/) + const transferredBody = await new Response(new Uint8Array(await transferred.body()), { headers: transferred.headers() }).formData() + const metadata = transferredBody.get('metadata') + if (typeof metadata !== 'string') throw new Error('missing PNG metadata') + const { attachments } = JSON.parse(metadata) as { attachments: { path: string[]; codec: string; part: string }[] } + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ path: ['data'], codec: 'bytes' }) + expect(attachments[0]!.part).toEqual(expect.any(String)) + const transferredFile = transferredBody.get(attachments[0]!.part) + if (transferredFile === null || typeof transferredFile === 'string') throw new Error('missing PNG payload') + expect(Buffer.from(await transferredFile.arrayBuffer())).toEqual(TINY_PNG) const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) expect(await viewer.count()).toBe(0) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 0fd9f06075..47440f7c24 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek @@ -43,5 +43,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 69e631f4cd..84f93fc533 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: openrouter @@ -24,5 +24,4 @@ - button "编辑 acme-gateway": 编辑 - button "删除 acme-gateway": 删除 - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index e30e6cc2dd..d1047bfbb2 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: minimax-cn @@ -25,7 +25,7 @@ - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: 提供方默认 + - /placeholder: 提供商默认 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" @@ -59,5 +59,4 @@ - text: Acme 网关 自定义 - button "编辑 Acme 网关 (acme-gateway)": 编辑 - button "删除 Acme 网关 (acme-gateway)": 删除 - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index f294ff5977..0380871342 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - status: 已保存 minimax-cn。 - list: - listitem: @@ -16,5 +16,4 @@ - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index fcaeb76e56..6bc07d9474 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: minimax-cn @@ -33,9 +33,9 @@ - text: https://gateway.acme.example/v1 - text: API 协议 - combobox "API 协议": - - option "openai-completions" [selected] - - option "openai-responses" - - option "anthropic-messages" + - option "OpenAI Chat Completions" [selected] + - option "OpenAI Responses" + - option "Anthropic Messages" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" @@ -62,5 +62,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index d752bbb42a..269d5ebd35 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: minimax-cn @@ -19,5 +19,4 @@ - text: Acme Gateway 自定义 - button "编辑 Acme Gateway (acme-gateway)": 编辑 - button "删除 Acme Gateway (acme-gateway)": 删除 - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 8ce2f31098..4ddb04a13b 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -8,52 +8,57 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list - - text: 提供方 - - combobox "提供方": - - option "amazon-bedrock" - - option "ant-ling" - - option "anthropic" - - option "azure-openai-responses" - - option "baseten" - - option "cerebras" - - option "cloudflare-ai-gateway" - - option "cloudflare-workers-ai" - - option "deepseek" - - option "fireworks" - - option "github-copilot" - - option "google" - - option "google-vertex" - - option "groq" - - option "huggingface" - - option "kimi-coding" - - option "minimax" - - option "minimax-cn" [selected] - - option "mistral" - - option "moonshotai" - - option "moonshotai-cn" - - option "nvidia" - - option "openai" - - option "openai-codex" - - option "opencode" - - option "opencode-go" - - option "openrouter" - - option "qwen-token-plan" - - option "qwen-token-plan-cn" - - option "qwen-token-plan-individual" - - option "together" - - option "vercel-ai-gateway" - - option "xai" - - option "xiaomi" - - option "xiaomi-token-plan-ams" - - option "xiaomi-token-plan-cn" - - option "xiaomi-token-plan-sgp" - - option "zai" - - option "zai-coding-cn" - - text: API 密钥 - - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 - - group: 自定义设置 - - button "取消" - - button "保存" + - tablist "添加方式": + - tab "第三方模型提供商" [selected] + - tab "自定义模型 API" + - paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。 + - tabpanel "第三方模型提供商": + - text: 提供商 + - combobox "提供商": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "baseten" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index e02c9b2529..01fd8e7166 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,7 @@ - dialog "选择要添加的模型": - heading "选择要添加的模型" [level=2] - button "关闭" - - paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。 + - paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。 - searchbox "搜索模型" - button "取消全选" - list: diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index b11ef1f610..803e82b6f0 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek @@ -56,5 +56,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index a6f4185486..84a7261b65 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek @@ -50,5 +50,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 0279600e91..f955841948 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -8,56 +8,61 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek - img "API 密钥缺失" - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: 提供方 - - combobox "提供方": - - option "amazon-bedrock" - - option "ant-ling" - - option "anthropic" - - option "azure-openai-responses" - - option "baseten" - - option "cerebras" - - option "cloudflare-ai-gateway" - - option "cloudflare-workers-ai" - - option "deepseek" - - option "fireworks" - - option "github-copilot" - - option "google" - - option "google-vertex" - - option "groq" - - option "huggingface" - - option "kimi-coding" - - option "minimax" - - option "minimax-cn" [selected] - - option "mistral" - - option "moonshotai" - - option "moonshotai-cn" - - option "nvidia" - - option "openai" - - option "openai-codex" - - option "opencode" - - option "opencode-go" - - option "openrouter" - - option "qwen-token-plan" - - option "qwen-token-plan-cn" - - option "qwen-token-plan-individual" - - option "together" - - option "vercel-ai-gateway" - - option "xai" - - option "xiaomi" - - option "xiaomi-token-plan-ams" - - option "xiaomi-token-plan-cn" - - option "xiaomi-token-plan-sgp" - - option "zai" - - option "zai-coding-cn" - - text: API 密钥 - - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 - - group: 自定义设置 - - button "取消" - - button "保存" + - tablist "添加方式": + - tab "第三方模型提供商" [selected] + - tab "自定义模型 API" + - paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。 + - tabpanel "第三方模型提供商": + - text: 提供商 + - combobox "提供商": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "baseten" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index adbb742d0b..e3a98dc8f5 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -171,7 +171,10 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov // desc line carries the projected limits — copy that can only render after // the imageLimits projection crossed the RemoteMock transport. const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' }) - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { + types: ['Files'], files: [image], dropEffect: 'none', + items: [{ kind: 'file', getAsFile: () => image, webkitGetAsEntry: () => ({ isDirectory: false }) }], + } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') expect(overlay.textContent).toContain('Drag files or images here to add them') diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts index 1087f8be3d..3e2d664b93 100644 --- a/apps/web/tests/models-settings-recovery.e2e.ts +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -54,19 +54,21 @@ describe('web e2e: repairs a stored provider after catalog drift', () => { } }) - it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => { + it('shows the failed provider beside healthy providers and keeps the add action usable', async () => { onTestFailed(() => saveFailureShot(page, 'models-settings-recovery')) const dialog = page.getByRole('dialog', { name: '设置' }) expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1) expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1) expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1) expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1) - expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true) - expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).isEnabled()).toBe(true) await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) - await dialog.getByRole('button', { name: '添加提供方', exact: true }).click() - await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn') + await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).click() + // Both modes stay offered beside a failed route: the card opens on the + // third-party mode with the custom-API segment enabled. + expect(await dialog.getByRole('tab', { name: '自定义模型 API' }).isEnabled()).toBe(true) + await dialog.getByLabel('提供商', { exact: true }).selectOption('minimax-cn') await dialog.getByRole('button', { name: '保存', exact: true }).click() await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor() expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}') diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 888b5d5d54..a46439dcbb 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,5 +1,6 @@ // Web e2e scenario: the Models settings page end to end through the real -// wire — the add card offers the dormant pi-ai catalog, a blank key saves a +// wire — the one add card offers the dormant pi-ai catalog on its third-party +// mode and the create form on its custom-API mode, a blank key saves a // reference-free profile for provider-native auth, and typing an API key later // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) // while the settings document records only that reference. Each saved row @@ -74,16 +75,20 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '模型' }).click() - await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await dialog.getByText('填入各提供商的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '添加提供方' }) + const add = dialog.getByRole('button', { name: '添加模型提供商' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) await add.click() - const pick = dialog.getByLabel('提供方') + // The card opens on the third-party mode, whose panel carries the select. + const modes = dialog.getByRole('tablist', { name: '添加方式' }) + await modes.waitFor({ timeout: 10_000 }) + expect(await modes.getByRole('tab', { name: '第三方模型提供商' }).getAttribute('aria-selected')).toBe('true') + const pick = dialog.getByLabel('提供商', { exact: true }) await pick.waitFor({ timeout: 10_000 }) await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30) const options = await pick.locator('option').allTextContents() @@ -239,22 +244,29 @@ describe('web e2e: Models settings page configures a dormant provider', () => { it('declares a route the adapter does not ship', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) - const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) - await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true) - await declare.click() - await dialog.getByLabel('Provider ID').fill('acme-gateway') - await dialog.getByLabel('显示名称').fill('Acme Gateway') - await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') + const add = dialog.getByRole('button', { name: '添加模型提供商' }) + await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) + await add.click() + // The custom-API mode is the second segment of the one add card; its + // purpose line replaces the catalog one. + await dialog.getByRole('tab', { name: '自定义模型 API' }).click() + await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 }) + // The third-party panel stays mounted, hidden, beside this one, so the + // form is addressed through its own panel. + const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' }) + await custom.getByLabel('Provider ID').fill('acme-gateway') + await custom.getByLabel('显示名称').fill('Acme Gateway') + await custom.getByLabel('API 地址').fill('https://gateway.acme.example/v1') // No reasoning effort on a provider card at all: effort is a per-model // capability, the models under one provider disagree about it, and a // switch in the composer already records provider+model+effort together. expect(await dialog.getByLabel('推理强度').count()).toBe(0) - await dialog.getByRole('button', { name: '添加模型' }).click() - await dialog.getByLabel('模型 ID 1').fill('acme-large') - await dialog.getByRole('button', { name: '模型选项 1' }).click() - expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) - await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() - await dialog.getByRole('button', { name: '创建提供方', exact: true }).click() + await custom.getByRole('button', { name: '添加模型', exact: true }).click() + await custom.getByLabel('模型 ID 1').fill('acme-large') + await custom.getByRole('button', { name: '模型选项 1' }).click() + expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) + await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() + await custom.getByRole('button', { name: '创建提供商', exact: true }).click() const row = dialog.getByText('Acme Gateway', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 96ddb5e24c..4b4f6e0929 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -99,7 +99,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first() await deepSeekRow.waitFor({ timeout: 10_000 }) await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() @@ -199,7 +199,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const deepSeek = settings.getByText('DeepSeek', { exact: true }).first() await deepSeek.waitFor({ timeout: 10_000 }) await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() @@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup for (let index = 0; index < 2; index++) { await settings.getByRole('button', { name: /删除模型/ }).first().click() } - await settings.getByRole('button', { name: '添加模型' }).click() + await settings.getByRole('button', { name: '添加模型', exact: true }).click() const customModelId = settings.getByLabel('模型 ID 1') await customModelId.fill('private-preview') await settings.getByLabel('显示名称 1').fill('Private Preview') diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index d16ed51822..42a2593487 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -54,14 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- await settings.waitFor({ timeout: 10_000 }) // Dismissing the onboarding step leaves Settings closed, so enter the // Models section explicitly before exercising its normal cards. - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) await setupKey.waitFor({ timeout: 10_000 }) - const add = settings.getByRole('button', { name: '添加提供方' }) + const add = settings.getByRole('button', { name: '添加模型提供商' }) await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) await add.click() - const pick = settings.getByLabel('提供方') + const pick = settings.getByLabel('提供商', { exact: true }) await pick.waitFor({ timeout: 10_000 }) await pick.selectOption('minimax-cn') await expect.poll( @@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- // Cancelling the setup card must not close the independent add-provider // draft beside it. await settings.getByRole('button', { name: '取消', exact: true }).first().click() - expect(await settings.getByLabel('提供方').count()).toBe(1) + expect(await settings.getByLabel('提供商', { exact: true }).count()).toBe(1) await expect.poll( async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), { timeout: 10_000 }, @@ -115,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- // setup card over a user who already has somewhere to send a request. await page.getByRole('button', { name: '设置', exact: true }).click() await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 050a928221..0d21a890ac 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -100,8 +100,8 @@ describe('web e2e: settings modal and General preferences', () => { const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) // Section switch: aria-current moves (the Models page itself has its own scenario file). - await dialog.getByRole('button', { name: '模型' }).click() - await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await expect.poll(() => dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() // Built-in plugins: the read-only Plugin list, a projection of the same // assembled Loader tree, shown as the section's one page; management and @@ -143,7 +143,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') // One contribution shows as the page itself, without a tab row. expect(await dialog.getByRole('tab').count()).toBe(0) - expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() + expect(await dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current')).toBeNull() const pluginsSnapshot = await captureStableAria( page, PLUGIN_ROW_SELECTOR, diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts index 2a0fe8caf4..5f797f19e2 100644 --- a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts +++ b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts @@ -1,9 +1,10 @@ -// Web e2e scenario: hovering a sidebar session row reveals a title wider than -// its cell. The jsdom lane pins only the scroll position the handler writes; -// the assembled browser is where the title really clips, really reaches its own -// hovered extent (the trailing relative-time cell yields to the row menu while -// the pointer rests on the row, so the hovered cell is wider than the resting -// one), and really returns to its start when the pointer leaves. +// Web e2e scenario: hovering a sidebar session row marquees a title wider than +// its cell. The jsdom lane pins only the positions the handler writes; the +// assembled browser is where the title really clips, really crawls toward its +// own hovered extent (the trailing relative-time cell yields to the row menu +// while the pointer rests on the row, so the hovered cell is wider than the +// resting one), really fades its moved left edge, and really returns to its +// start when the pointer leaves. // // Zero model calls: seeding one renamed session and hovering its row touches no // provider. @@ -16,10 +17,10 @@ import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from ' import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ +/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the marquee must reach. */ const TITLE = 'Forked session clipped before its suffix (1)' -describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { +describe('web e2e: hovering a clipped session title marquees it to its far edge', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -43,7 +44,7 @@ describe('web e2e: hovering a clipped session title scrolls it to its far edge', await scaffold?.close() }) - it('reveals the clipped title under the pointer and restores its start after', async () => { + it('crawls the clipped title under the pointer and restores its start after', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) await row.waitFor({ timeout: 20_000 }) @@ -53,34 +54,56 @@ describe('web e2e: hovering a clipped session title scrolls it to its far edge', expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') expect(await title.evaluate(el => el.scrollLeft)).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') await row.hover() - // Poll against the live extent: the reveal must reach the far edge of the - // hovered cell (the wider one — the relative-time label yields to the row - // menu), whatever width the handler itself read. + // The marquee crawls instead of jumping: the title is caught mid-travel, + // strictly between its start and its far edge, with both cut edges + // publishing their fade-mask hooks. + await expect.poll( + async () => title.evaluate(el => el.scrollLeft > 0), + { timeout: 5_000 }, + ).toBe(true) + expect(await title.evaluate(el => el.scrollLeft < el.scrollWidth - el.clientWidth - 1)).toBe(true) + expect(await title.evaluate(el => el.hasAttribute('data-scrolled'))).toBe(true) + expect(await title.evaluate(el => el.hasAttribute('data-clipped'))).toBe(true) + expect(await title.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient') + // The crawl still reaches the far edge of the hovered cell (the wider + // one — the relative-time label yields to the row menu) and rests there, + // lifting the right fade so the final character reads at full strength. + // The fade lifts on the frame that lands exactly on the far edge, one or + // two frames after scrollLeft first rounds within a pixel of it. await expect.poll( async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), - { timeout: 10_000 }, + { timeout: 20_000 }, ).toBe(true) - // Scrolled to its end, the title must not paint the ellipsis over the - // characters the reveal reached. + await expect.poll( + async () => title.evaluate(el => el.hasAttribute('data-clipped')), + { timeout: 5_000 }, + ).toBe(false) + // At its extent, the title must not paint the ellipsis over the + // characters the marquee reached. expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') - // Leaving returns in one step: a smooth return would still be travelling - // inside this window, and it would carry the resting ellipsis back with it. + // Leaving returns in one step: a crawl back would still be travelling + // inside this window, and it would carry the resting ellipsis with it. await page.mouse.move(0, 0) await expect.poll( async () => title.evaluate(el => el.scrollLeft), { timeout: 150, interval: 20 }, ).toBe(0) + expect(await title.evaluate(el => el.hasAttribute('data-scrolled'))).toBe(false) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - // The reduce override reaches the real stylesheet, so the reveal jumps - // instead of gliding. + // Reduced motion reaches the handler's matchMedia probe: the reveal jumps + // to the far edge instead of crawling. await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') + await row.hover() + await expect.poll( + async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), + { timeout: 500, interval: 20 }, + ).toBe(true) + await page.mouse.move(0, 0) await page.emulateMedia({ reducedMotion: null }) expect(tripwire.pageErrors).toEqual([]) - }, 60_000) + }, 90_000) }) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 628803ad48..1b6a9e7f10 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -8,11 +8,12 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v3.jsonl', import.meta.url)) +// Any generation path names the role; the owner's highest committed generation replays. +const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v4.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -26,7 +27,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { let tripwire: ReturnType beforeAll(async () => { - const fixture = await readFile(FIXTURE, 'utf8') + const fixture = await readFile(await selectedSessionFixture(FIXTURE), 'utf8') expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({}) await seedSession(scaffold, fixture, SEED_ID) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 018bb1abc0..e11ee881f4 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 52e91c48104ac8a8a46ad27f2b329ca72d6abc06 -api-gateway.zh.md: 9f062eaa6e6618354afa9ed1101e9cf6ed67d465 +api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae +api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 52e91c4810..957f407758 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -122,9 +122,9 @@ Strict analysis requires a Remote to be a public, non-static instance method wit Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; feature-owned exact Fetch routes handle non-JSON responses, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; it projects binary Remote fields into JSON-compatible metadata and result-relative byte attachments, which Connection frames as multipart responses. Feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. Connection owns transport, RPC ids, response envelopes, and request cancellation, while Gateway owns the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. @@ -160,6 +160,6 @@ The running Client watcher consumes these generated files when it rebundles. If Remote handles unary method calls with one request and one result, and stream methods whose items flow Host → Client while the Client's items reach the running Host method through `this.ctx.invocation.uplink()`. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. -The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. Unary results containing `Uint8Array`, including nested fields and array entries, use [binary Remote transfer](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md). A feature that needs a response outside the RPC envelope registers an exact Connection Fetch route. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 9f062eaa6e..1062adcc89 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -122,9 +122,9 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;功能自有的精确 Fetch 路由处理非 JSON 响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;它把二进制 Remote 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 multipart 响应。功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 -Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 +Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 @@ -160,6 +160,6 @@ pnpm run build:lib Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Client 方向流动、而 Client 的项经 `this.ctx.invocation.uplink()` 到达正在运行的 Host 方法的流方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 -API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。包含 `Uint8Array` 的一元结果(包括嵌套字段和数组条目)使用[二进制 Remote 传输](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md)。需要 RPC 信封之外响应的功能注册精确的 Connection Fetch 路由。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index bb5e63f987..a31bcf99da 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: b6acbd0906610469b96b2bdcc22410440ea237ff -architecture.zh.md: ea2df78fbd4a90a6a671f4c917980e97ef02d8f8 +architecture.md: 514bd52cfa8f50fe8cf73c391094d34942b3cca3 +architecture.zh.md: 848fa1b3810403e4a3906ac491c8552052e4ee4e diff --git a/docs/architecture.md b/docs/architecture.md index b6acbd0906..514bd52cfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction and cancellation-cause rules. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ea2df78fbd..848fa1b381 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 +循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造与取消原因记录规则。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 49f9303865..c1cf209b2c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 31b4df25d762c25deb1d591e8a44f51a0993f1c2 -config-catalog.zh.md: ce3bf45a781092665652ec0298a57ca5b760add9 +config-catalog.md: 1fac327a8a50a09cf694f6fc31a0af1f031f596f +config-catalog.zh.md: fbc043ab62d57d1b7f754e28d3469cc5ebd4fb49 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 31b4df25d7..1fac327a8a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -199,7 +199,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) @@ -338,7 +338,7 @@ export interface Config { } ``` -Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) +Source: [`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) @@ -466,7 +466,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) @@ -2966,7 +2966,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:247`](../packages/core/system-prompt/src/index.ts) @@ -3931,7 +3931,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) -- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) +- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts)) - `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ce3bf45a78..fbc043ab62 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -201,7 +201,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) @@ -342,7 +342,7 @@ export interface Config { } ``` -来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) +来源:[`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) @@ -470,7 +470,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) @@ -2970,7 +2970,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:247`](../packages/core/system-prompt/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 33dfb05b8e..bb2e2b647b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 8101bb9aa3d6e3da1cca96a5593de5c52d6454cd -event-producer-consumer.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c +event-producer-consumer.md: 2b373821a2d0c9acf2284c4195a928947700f1ce +event-producer-consumer.zh.md: 90ca2a4c76ac477265a60dad80c5f65d04509e90 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8101bb9aa3..2b373821a2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -41,9 +41,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a57e1b7e94..90ca2a4c76 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index feb1c96792..bab89e6efd 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -150,7 +150,7 @@ | merge | 合并 | | | | | message | 消息 | | | | | mod | 模组 | | | | -| model provider | 模型提供方 | | | | +| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 | | model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 | | module | 模块 | | | | | non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 | @@ -168,7 +168,7 @@ | plugin | 插件 | | | | | postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 | | prompt | 提示词 | | | | -| provider | 提供方 | | | | +| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 | | provider-neutral | 提供方无关 | | 提供方中立 | | | quality gate | 质量门禁 | | | | | quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f90ee530e1..e22d460cd0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e0f1fa5f504b04a2ee4e33e2d8fa4013f70cd7a1 -module-graph.zh.md: f4ad086634178aaa79371a6adc6b9e14d2c89e64 +module-graph.md: af297ec0b0d143ad8917eac00e2faf44f3477b5d +module-graph.zh.md: f56bdc422d9e74d922cb015c9d35e020cbd69b89 diff --git a/docs/module-graph.md b/docs/module-graph.md index e0f1fa5f50..af297ec0b0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -994,7 +994,6 @@ flowchart TD pkg_experimental_auto_review --> pkg_tools pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand @@ -1557,7 +1556,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f4ad086634..f56bdc422d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -996,7 +996,6 @@ flowchart TD pkg_experimental_auto_review --> pkg_tools pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand @@ -1559,7 +1558,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index e1fd460b35..3c4fdb398a 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: bba024e8ee4cb89ceac4698bf66f060c7f74dcd6 -core.zh.md: 2e04d5a9a078f3411b2a0d338835ec7d71296f10 +core.md: a1e876dcfcd31dfb9fd4840d0d0e217bb71e43bb +core.zh.md: a63e38cf6381d97656f2350cc16502030772b1a7 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index bba024e8ee..a1e876dcfc 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -297,7 +297,7 @@ type AgentCancelCause = | { readonly kind: 'disposed' } ``` -The cause is a TypeScript-enforced same-process input. An active cancellation holder copies it into the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` records the outcome as `{ kind: 'aborted', reason: TurnEndCancelCause }`, so the cancel cause lands in the terminal result. +The cause is a TypeScript-enforced same-process input. An active cancellation holder exposes that same object as the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` records the outcome as `{ kind: 'aborted', reason: TurnEndCancelCause }`, so the cancel cause lands in the terminal result. The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2e04d5a9a0..a63e38cf63 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -301,7 +301,7 @@ type AgentCancelCause = | { readonly kind: 'disposed' } ``` -cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 以 `{ kind: 'aborted', reason: TurnEndCancelCause }` 记录结果,取消原因随终态结果一起持久化。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者把同一个对象暴露为仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 以 `{ kind: 'aborted', reason: TurnEndCancelCause }` 记录结果,取消原因随终态结果一起持久化。 [事件分类](../architecture.zh.md#events)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 06d2e4aa51..c04ea7446f 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md -filesystem.md: a4ed5460ac333b57520b241f31f5c809e574f858 -filesystem.zh.md: 6f06c140c1a9a2c685aebd6339cd1ee97d4303bb +filesystem.md: d099e619b208f5984db6ff4561cd203d13910ff0 +filesystem.zh.md: 6e907d07b82c72453ef45d67e6bcb9809431e20a diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index a4ed5460ac..d099e619b2 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -300,7 +300,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ -abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> +watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 6f06c140c1..6e907d07b8 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -300,7 +300,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ -abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> +watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a diff --git a/docs/subsystems/office-to-pdf.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml index 7f4afe0589..200c1c670b 100644 --- a/docs/subsystems/office-to-pdf.i18n.yaml +++ b/docs/subsystems/office-to-pdf.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md -office-to-pdf.md: 65f46ded856e81f9bcd7890c11c82f0a22f769c4 -office-to-pdf.zh.md: df46af01d78241bbc88f6b85a83c47551faf4bfe +office-to-pdf.md: dd0f6392f264775f12c0f7612656f1c07366567d +office-to-pdf.zh.md: 5c99f87681dd8d98da3717a311a7f858d4b52b32 diff --git a/docs/subsystems/office-to-pdf.md b/docs/subsystems/office-to-pdf.md index 65f46ded85..dd0f6392f2 100644 --- a/docs/subsystems/office-to-pdf.md +++ b/docs/subsystems/office-to-pdf.md @@ -29,7 +29,7 @@ The provider admits the deferred read before allocating source bytes, shares con ## Preview reads -`RenderedDocumentBytes` extends the workspace byte response with `missingFonts` and `generation`; the original source identity accompanies the converted PDF. +`RenderedDocumentBytes` carries workspace file metadata, base64 PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF. The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The response carries base64 PDF bytes with the source absolute path and freshness version. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events. diff --git a/docs/subsystems/office-to-pdf.zh.md b/docs/subsystems/office-to-pdf.zh.md index df46af01d7..5c99f87681 100644 --- a/docs/subsystems/office-to-pdf.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -29,7 +29,7 @@ ## 预览读取 -`RenderedDocumentBytes` 在工作区字节响应上增加 `missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 +`RenderedDocumentBytes` 携带工作区文件元数据、base64 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 `officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 091c70c940..b4d97350d4 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: e3a4ffcf4e4b17975c10c0358f6f53dc5eea6ec2 -sidebar-right.zh.md: a588dc99b92314c54ea1309e4a44d305e23b970f +sidebar-right.md: bade9a958a62368b84ddfae8b5dbe869dfe11e4a +sidebar-right.zh.md: 93a6f1f25171b19fef5876e353e4b8be46aef78d diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index e3a4ffcf4e..bade9a958a 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -112,7 +112,7 @@ The `text` tab is the shared Document Preview owner. Its [root registration](../ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. Suffixes declared in `binaryExtensions` suppress the plain-text alternative, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages`, `bytes-complete`, or `renderer`; `wrap` advertises support for the shared source-wrap control. -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads text through `remote.workspaceFiles.read` and bytes through `remote.workspaceFiles.readBytes`, and owns page appends, per-tab refresh, and loading status. HTML passes `baseFile` to the same byte reader; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). @@ -128,9 +128,9 @@ A resource stays open while it has a holder — a subscribed `useResource` or a ## Workspace Files -The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). +The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { range?, baseFile? })` returns a complete file under `maxFileBytes` or a byte window under `maxBytes` as `{ …stat, offset, data, eof }` with native `Uint8Array` data. Its optional `range` uses 0-based `offset` and a byte `length`; `baseFile` resolves the relative target from another file’s directory. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results. +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's Remote callbacks use the Session in that address; `remote.workspaceFiles.readBytes` returns native bytes through Connection's binary RPC transport. ## Shipped types diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index a588dc99b9..93a6f1f251 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -112,7 +112,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed [注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages`、`bytes-complete` 或 `renderer`;`wrap` 声明是否支持共享的源码换行控件。 -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过 `remote.workspaceFiles.read` 读取文本,通过 `remote.workspaceFiles.readBytes` 读取字节,拥有追加分页、逐 tab 刷新与加载状态。HTML 向同一个字节读取方法传入 `baseFile`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 @@ -128,9 +128,9 @@ Preview 记录已载入版本和读取开始时的观察版本。自动刷新默 ## Workspace Files -Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区内;订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 +Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { range?, baseFile? })` 在 `maxFileBytes` 上限内返回完整文件,或在 `maxBytes` 上限内返回字节窗口,形如 `{ …stat, offset, data, eof }`,其中 `data` 为原生 `Uint8Array`。可选 `range` 使用 0 起算的 `offset` 与字节数 `length`;`baseFile` 从另一文件所在目录解析相对目标。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区。订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。 +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的 Remote 回调使用地址中的 Session;`remote.workspaceFiles.readBytes` 通过 Connection 的二进制 RPC 传输返回原生字节。 ## 内置类型 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 2f43dc6d54..e63a0fe9d5 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/typert.md -typert.md: a8192b319215957b8d373ec01f6f29d73e165e45 -typert.zh.md: 08d1e8f466ef1e516c04426165b8180bd9881c83 +typert.md: db8b48945e2301e14d866b8a2e3df59c4ae408c9 +typert.zh.md: cdaee66201d0f27a5a18aa99cb1888438051e018 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index a8192b3192..db8b48945e 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -38,7 +38,7 @@ interface TypertLookupDefinition { ## Invocation descriptors -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 values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. +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`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -48,6 +48,19 @@ type TypertCodec = 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 + /** + * 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 mode: 'src-json' diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 08d1e8f466..cdaee66201 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -38,7 +38,7 @@ interface TypertLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 +`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`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -48,6 +48,19 @@ type TypertCodec = 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 + /** + * 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 mode: 'src-json' diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 0f1163393e..f89b3c13dc 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: c43b7553d1a223b82fec73c7431e4655cf1cbee1 -web-client.zh.md: d98febb7494a2b2fe082f8544850dbf67ee75794 +web-client.md: 80d233b634f57bb150d8eb6604428557e1e16033 +web-client.zh.md: f14cdbe9b1d5605b90bd595a384ef7ac9c0d7286 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index c43b7553d1..80d233b634 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -27,7 +27,7 @@ The Web boot kernel creates the module system, prefetches `immediately` entries, Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. -The Connection owns request correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. +The Connection owns request URL resolution, correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. The internal `$events` logical stream is the Connection generation source. Its opening `ready` frame carries the Host home used for path display and establishes the generation after Host listeners are attached, before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index d98febb749..f14cdbe9b1 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -27,7 +27,7 @@ Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendore Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 -Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 +Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 内部 `$events` logical stream 是 Connection generation source。它的 opening `ready` frame 携带用于路径显示的 Host home,并在 Host listener 已挂载、任何 controller 开始 baseline read 之前建立 generation。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 452d200cf5..b03f962bef 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 323323946ac92bbc03962b83c5e77a81a960d89a -workspace.zh.md: aedd1134305aaa7e97ccd449631ff72446bf28b4 +workspace.md: 8a3369778f0eac10d5fe077686d336eb47f6b41e +workspace.zh.md: f1e7e96f1fb5e983de30bd8e2f9fe08ed0635beb diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 323323946a..8a3369778f 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -397,34 +397,14 @@ Host Remote file reads and workspace directory observations over the composed fi @Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ -@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise - -/** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise - -/** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ -@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index aedd113430..f1e7e96f1f 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -397,34 +397,14 @@ Host Remote file reads and workspace directory observations over the composed fi @Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ -@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise - -/** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise - -/** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ -@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 54934936dc..8cdff8b24e 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: a4441a1b6bdbd0c2f7cedf506244a8a4a2a1ca97 -tool-catalog.zh.md: 0a82afad88f5362ca997f2e563f1b53c4bf633e8 +tool-catalog.md: c60d3979e65f3c5a2bc363fd082a3f63a446e8b5 +tool-catalog.zh.md: dc061b51681cc73f9d10059276bcdfa5b3268cff diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a4441a1b6b..c60d3979e6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -732,7 +732,7 @@ Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/ ### `cordis_inspect_query` -Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant. +Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 0a82afad88..dc061b5168 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -736,7 +736,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `cordis_inspect_query` -执行 Inspect Provider 明确声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。对于 Service.listService 和 Event.listEvents,不传 input 可浏览精简签名目录,再查询准确服务或事件以获得完整约定及引用类型。对于 Slots.listSubTree,不传 root 可浏览精简树;查询准确的 Slot root 可获得完整注册约定和 props,而查询准确的 Factory root 只返回 identity、scope 与 registrant。 +执行 Inspect Provider 声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。 ```json { diff --git a/docs/user/guide/providers-custom-form.png b/docs/user/guide/providers-custom-form.png index bbbedde794..82ca074212 100644 Binary files a/docs/user/guide/providers-custom-form.png and b/docs/user/guide/providers-custom-form.png differ diff --git a/docs/user/guide/providers-custom-form.zh.png b/docs/user/guide/providers-custom-form.zh.png index 2c48120331..9a2b7661ca 100644 Binary files a/docs/user/guide/providers-custom-form.zh.png and b/docs/user/guide/providers-custom-form.zh.png differ diff --git a/docs/user/guide/providers-models-page.png b/docs/user/guide/providers-models-page.png index f3ffe6e90f..fb87367d54 100644 Binary files a/docs/user/guide/providers-models-page.png and b/docs/user/guide/providers-models-page.png differ diff --git a/docs/user/guide/providers-models-page.zh.png b/docs/user/guide/providers-models-page.zh.png index b5d1f4597c..694f1b6f16 100644 Binary files a/docs/user/guide/providers-models-page.zh.png and b/docs/user/guide/providers-models-page.zh.png differ diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 3097c5ae9e..f0043c374d 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1 -providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495 +providers.md: ef016ef39d5e34f3ce3dd2feb6213b9597668592 +providers.zh.md: 15fd229ce222a020e897f08c15afcd843791a3bb diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index e578ff96a3..ef016ef39d 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -8,21 +8,21 @@ This guide assumes you started the Web UI through the [root README](../../../REA Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter the key and save it. -![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) +![The Models page: the DeepSeek card, with Add model provider below it](providers-models-page.png) Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. -## Add a built-in provider +## Add a third-party provider -Choose **Add provider** and pick a provider dsh ships with; the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM. Enter its API key and save. The installed catalog supplies the endpoint, protocol, and model list. +Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. Providers that sign in with OAuth, such as Codex, are not supported here yet. -## Add a custom provider +## Add a custom model API -Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the form offers three: `openai-completions` for OpenAI Chat Completions, `openai-responses` for the OpenAI Responses API, and `anthropic-messages` for the Anthropic Messages API. A provider speaks one protocol, so a gateway that serves two needs two providers. +Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in `settings.yaml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers. -![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) +![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index fd0859f0b2..15fd229ce2 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -8,39 +8,39 @@ 打开**设置 → 模型**。DeepSeek 卡片提供一个 API 密钥字段;输入密钥并保存。 -![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) +![模型页:DeepSeek 卡片,及其下方的添加模型提供商入口](providers-models-page.zh.png) 密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 -## 添加内置提供方 +## 添加第三方提供商 -选择**添加提供方**,选取 dsh 自带的提供方;列表显示的是提供方 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`。输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 +选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -通过 OAuth 登录的提供方(例如 Codex)暂不支持。 +通过 OAuth 登录的提供商(例如 Codex)暂不支持。 -## 添加自定义提供方 +## 添加自定义模型 API -对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,表单提供三种:`openai-completions` 对应 OpenAI Chat Completions,`openai-responses` 对应 OpenAI Responses API,`anthropic-messages` 对应 Anthropic Messages API。一个提供方只使用一种协议,网关同时提供两种时需要建两个提供方。 +对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在 `settings.yaml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。 -![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) +![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) -Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。 +Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供商,请添加新提供商并删除旧提供商。显示名称、基础 URL、协议、凭据和模型仍可编辑。 ### 探测模型 -在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供方则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供方之前不会存储任何内容。 +在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。 -探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供方一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供方探测。 +探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。 ## 选择模型 -已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 +已配置的提供商会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 -如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 +如果已保存默认值指向已删除的提供商,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 ## 进阶配置 -自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 +自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供商段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 ::: tip 其他设置 模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。 @@ -48,9 +48,9 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和 ### 图片输入 -在**设置 → 模型**中编辑提供方,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。 +在**设置 → 模型**中编辑提供商,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。 -复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供方声明了一个纯文本模型和一个视觉模型: +复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供商声明了一个纯文本模型和一个视觉模型: ```yaml llm-pi-ai: @@ -86,7 +86,7 @@ llm-pi-ai: - id: second-model ``` -`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键: +`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供商上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供商没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键: ```yaml llm-pi-ai: @@ -99,11 +99,11 @@ llm-pi-ai: 在 pi-ai 配置中,除模型自身的 `input` 外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。 -这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。 +这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供商拒绝该请求。 ### 推理等级 -对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供方的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级: +对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级: ```yaml llm-pi-ai: @@ -136,7 +136,7 @@ llm-pi-ai: max: max ``` -网关并不提供推理能力的内置提供方模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值: +网关并不提供推理能力的内置提供商模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值: ```yaml llm-deepseek: @@ -181,8 +181,8 @@ llm-pi-ai: ## 排错 -- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 -- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 +- **`MISSING_CREDENTIAL`**:通过模型页存储提供商密钥,或提供被引用的环境变量。 +- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供商添加缺失的模型。 - **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 - **获取可用模型提示既没有 `data` 数组也没有 `models` 对象**:端点返回的列表格式不在探测的读取范围内。请手动输入模型。 - **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。 @@ -190,5 +190,5 @@ llm-pi-ai: - **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `settings.yaml` 中给该模型加上 `reasoningEfforts`。 - **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。 - **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。 -- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。 -- **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 +- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供商的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。 +- **提供商拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 5f05ce5e1e..832baf5fd0 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 996e68fd2b99989523b01272c693d4039fdc9f44 -README.zh.md: 23b83fbf2c7559434dfd47d6b06994a8d390d1ac +README.md: 71171ef65c9113315cbd401a6b59eefb5a9a45a1 +README.zh.md: f9f2068eb593acc25f1fd384d08979d5c48c7743 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 996e68fd2b..71171ef65c 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -26,7 +26,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, and invokes the public business method. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. For unary results, Gateway executes the generated codec's `encode()` when present and otherwise keeps strict JSON values unchanged; SRC recursively detects runtime byte values. Both paths return JSON-compatible metadata and result-relative byte attachments to Connection for transport framing. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. @@ -45,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. The Client delegates unary result conversion to the codec's optional `decode()`. Generated decoders validate nested metadata and native byte types without iterating, copying, or freezing byte payloads. JSON unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 23b83fbf2c..f9f2068eb5 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -26,7 +26,7 @@ kind: "package-reference" 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,并调用公开的业务方法。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。对于一元结果,Gateway 在生成 codec 提供 `encode()` 时执行它,否则保持 strict JSON 值不变;SRC 则递归识别运行时字节值。两条路径都向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件,由 Connection 完成传输帧封装。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。Client 将一元结果转换交给 codec 的可选 `decode()`。生成的解码器校验嵌套元数据与原生字节类型,不逐字节遍历、复制或冻结字节载荷。JSON 一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 43992cb60e..9a6cb07d16 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -444,8 +444,12 @@ class ClientRemoteService extends Service implements ClientRemote { try { const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) if (!mountActive(token)) return withdrawn(endpoint) + prepared.signal.throwIfAborted() if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) } - return { ok: true, value: result.value } + const value = descriptor.result.mode === 'strict' && descriptor.result.decode !== undefined + ? descriptor.result.decode(result.value) + : result.value + return { ok: true, value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly // faults, so they join the same error branch. A caller-aborted call is a diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 02b4c7fbbb..d9bc91d52a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,7 +7,11 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import { OperatorPeer, type ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { + OperatorPeer, + type ConnectionRpcAttachment, + type ConnectionRpcHandler, +} from '@deepseek-ai/dsh-client-connection' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -325,7 +329,10 @@ export class TypertGatewayService extends Service implements TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { - const prepared = await this.prepareInvocation(request, new AbortController()) + return this.invokePrepared(await this.prepareInvocation(request, new AbortController())) + } + + private async invokePrepared(prepared: PreparedInvocation): Promise { if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', @@ -337,7 +344,7 @@ export class TypertGatewayService extends Service implements TypertGateway { try { return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } finally { // A unary call's uplink is readable only while the method runs. @@ -647,11 +654,15 @@ export class TypertGatewayService extends Service implements TypertGateway { peer: PeerScope, ): Promise { try { - const value = await this.invoke(remoteRequest(endpoint, payload, signal, peer)) + const prepared = await this.prepareInvocation( + remoteRequest(endpoint, payload, signal, peer), + new AbortController(), + ) + const value = await this.invokePrepared(prepared) // A void or explicitly absent business result carries no `value` field; // JSON has no `undefined`, and the envelope's optional slot is the one // representation of absence that both args and results already use. - return { ok: true, value } + return encodeRpcResult(value, prepared.descriptor.result) } catch (error) { return rpcFailure(error) } @@ -963,6 +974,67 @@ export class TypertGatewayService extends Service implements TypertGateway { } } +function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult { + const attachments: ConnectionRpcAttachment[] = [] + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ path: [...path], bytes }) + return null + } + const encoded = codec.mode === 'strict' + ? codec.encode?.(value, writeBytes) ?? value + : encodeRuntimeResult(value, writeBytes) + return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) } +} + +function encodeRuntimeResult( + input: unknown, + writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null, +): unknown { + const path: (string | number)[] = [] + const ancestors = new Set() + const extract = (input: unknown, key: string): unknown => { + // Capture accessors and toJSON once, before materializing the JSON metadata. + let value = input + if (input !== null && typeof input === 'object' && !(input instanceof Uint8Array)) { + const toJSON: unknown = Reflect.get(input, 'toJSON') + if (typeof toJSON === 'function') value = Reflect.apply(toJSON, input, [key]) + } + if (value instanceof Uint8Array) return writeBytes(value, path) + if (typeof value !== 'object' || value === null) return value + if (value instanceof Number || value instanceof String || value instanceof Boolean) return value.valueOf() + if (ancestors.has(value)) throw new TypeError('gateway: circular RPC result') + ancestors.add(value) + let copy: object + if (Array.isArray(value)) { + const items: unknown[] = [] + // JSON arrays include every index, even holes and non-enumerable elements. + for (let index = 0, length = value.length; index < length; index++) items.push(child(value[index], index)) + copy = items + } else { + const fields: Record = {} + for (const key of Object.keys(value)) { + const item: unknown = Reflect.get(value, key) + // A toJSON method on the projected object must not run a second time. + if (key === 'toJSON' && typeof item === 'function') continue + const extracted = child(item, key) + if (key === '__proto__') Object.defineProperty(fields, key, { value: extracted, enumerable: true }) + else fields[key] = extracted + } + copy = fields + } + ancestors.delete(value) + return copy + } + const child = (value: unknown, key: string | number): unknown => { + if (typeof value !== 'object' || value === null) return value + path.push(key) + const extracted = extract(value, String(key)) + path.pop() + return extracted + } + return extract(input, 'value') +} + type RemoteEventWireFrame = | RemoteEventEmitFrame | RemoteEventInvocationFrame diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index ddd6a846b8..aeec14ecf1 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -24,7 +24,7 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject, RemoteStream } from '../src/client/index.ts' +import { apply, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' import { ClientUplinkQueue, RemoteStreamCarrierError, @@ -95,6 +95,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { signal?: AbortSignal, ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> + 'probe/bytes': (signal?: AbortSignal) => Promise; readonly offset: number }>> 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable 'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle } @@ -3156,6 +3157,96 @@ async function withFakeWebSocket( } } +describe('Remote result decoders', () => { + const schema = z.object({ data: z.instanceof(Uint8Array), offset: z.number() }) + const descriptor: InvocationDescriptor = { + id: '@fixture/probe#probe/bytes', + service: 'probe', namespace: 'probe', method: 'bytes', + invocation: { kind: 'direct' }, parameters: [], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', typeSymbol: '@fixture#Bytes', + create: () => schema, + decode: value => schema.parse(value), + }, + } + + it('validates metadata without copying or freezing native data on a logical RPC carrier', async () => { + const data = new Uint8Array([0, 128, 255]) + const call = vi.fn().mockResolvedValue({ ok: true, value: { data, offset: 2 } }) + const { ctx, client } = await benchFiber(call) + try { + await ctx.remote.$mount({ package: '@fixture/binary', descriptors: [descriptor] }) + const result = await ctx.remote.probe.bytes() + expect(result).toEqual({ ok: true, value: { data, offset: 2 } }) + if (result.ok) expect(result.value.data).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + for (const value of [{ data: 'base64', offset: 2 }, { data: [0, 128], offset: 2 }, { data, offset: '2' }]) { + call.mockResolvedValueOnce({ ok: true, value }) + const failure = await ctx.remote.probe.bytes() + expect(failure).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + if (!failure.ok) expect(isRemoteFailure(failure.error)).toBe(true) + } + } finally { + await client.dispose() + } + }) + + it('delegates nested fields to the result decoder and leaves JSON results untouched', async () => { + const data = new Uint8Array([0, 128, 255]) + const value = { files: [{ content: data }], thumbnail: data } + const call = vi.fn().mockResolvedValue({ ok: true, value }) + const decode = vi.fn((input: unknown) => z.object({ + files: z.array(z.object({ content: z.instanceof(Uint8Array) })), + thumbnail: z.instanceof(Uint8Array), + }).parse(input)) + const { ctx, client } = await benchFiber(call) + try { + const dispose = await ctx.remote.$mount({ + package: '@fixture/binary', descriptors: [{ + ...descriptor, result: { mode: 'strict', typeSymbol: '@fixture#Files', create: () => schema, decode }, + }], + }) + expect(await ctx.remote.probe.bytes()).toEqual({ ok: true, value }) + expect(decode).toHaveBeenCalledWith(value) + expect((decode.mock.results[0]?.value as ReturnType).files[0]?.content).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + await dispose() + const json = { base64: 'AP8=', items: [1, 2] } + call.mockResolvedValue({ ok: true, value: json }) + const create = vi.fn(() => schema) + await ctx.remote.$mount({ package: '@fixture/json', descriptors: [{ + ...descriptor, result: { mode: 'strict', typeSymbol: '@fixture#Json', create }, + }] }) + const result = await ctx.remote.probe.bytes() + if (result.ok) expect(result.value).toBe(json) + expect(create).not.toHaveBeenCalled() + } finally { + await client.dispose() + } + }) + + it.each(['cancel', 'withdraw'] as const)('discards late binary results after %s', async (action) => { + const response = Promise.withResolvers>>() + const call = vi.fn().mockReturnValue(response.promise) + const { ctx, client } = await benchFiber(call) + try { + const dispose = await ctx.remote.$mount({ package: '@fixture/binary', descriptors: [descriptor] }) + const abort = new AbortController() + const pending = ctx.remote.probe.bytes(abort.signal) + expect(call).toHaveBeenCalledOnce() + if (action === 'cancel') abort.abort() + else await dispose() + response.resolve({ ok: true, value: { data: new Uint8Array([7]), offset: 0 } }) + expect(await pending).toMatchObject({ + ok: false, error: { code: action === 'cancel' ? 'gateway/cancelled' : 'gateway/internal' }, + }) + } finally { + await client.dispose() + } + }) +}) + async function drainStream(source: AsyncIterable): Promise { const values: unknown[] = [] for await (const value of source) values.push(value) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 94f75e9138..24522ce846 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' -import type { HostConnectionHandle, PeerId, PeerScope } from '@deepseek-ai/dsh-client-connection' +import type { + ConnectionRpcHandler, + HostConnectionHandle, + PeerId, + PeerScope, +} from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -53,6 +58,7 @@ class GoalService extends Service { readonly typertRemote = bindTypertRemote(this, 'goals') readonly calls: string[] = [] lastSignal: AbortSignal | undefined + lastPeer: PeerScope | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -64,6 +70,7 @@ class GoalService extends Service { create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') this.lastSignal = signal + this.lastPeer = this.ctx.invocation?.peer return { agentId: agent.id, title: request.title, @@ -102,10 +109,7 @@ class GoalService extends Service { } } -type FakeRpcResult = - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } - +type FakeRpcResult = Awaited> type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise class FakeConnectionService extends Service { @@ -126,12 +130,12 @@ class FakeConnectionService extends Service { intercept: ( channel: string, matches: (endpoint: string) => boolean, - handler: FakeRpcHandler, + handler: ConnectionRpcHandler, ) => owner.effect(() => { this.channel = channel this.matches = matches - this.handler = handler + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) return () => { this.channel = undefined this.matches = undefined @@ -996,16 +1000,25 @@ describe('TypertGatewayService', () => { const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') - await expect(handler('goals/create', { + const success = await handler('goals/create', { args: { agentId: 'agent-1', request: { title: 'ship' } }, - }, signal)).resolves.toEqual({ + }, signal) + expect(success).toMatchObject({ ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + if (!success.ok) throw new Error('fixture Remote failed') + expect(success.attachments).toBeUndefined() const service = rawGoalService(ctx) expect(service.lastSignal).toBe(signal) abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'direct' } }, + })).resolves.toMatchObject({ title: 'direct' }) + expect(service.lastPeer).toBe(connection.operator) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1014,11 +1027,11 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) - await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({ + await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toMatchObject({ ok: true, value: undefined, }) - await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({ + await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toMatchObject({ ok: true, value: null, }) @@ -1068,6 +1081,112 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('projects strict and SRC result bytes before returning to Connection', async () => { + const { ctx } = await setup() + const fiber = ctx.plugin(FakeConnectionService) + await fiber + try { + const value = { content: new Uint8Array([0, 128, 255]) } + rawGoalService(ctx).nextResult = value + const handler = rawConnection(ctx).handler! + const source = await handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + expect(source).toEqual({ + ok: true, + value: { content: null }, + attachments: [{ path: ['content'], bytes: value.content }], + }) + const descriptor = passthroughDescriptor() + const codec = { + mode: 'strict' as const, + typeSymbol: '@fixture/gateway#Bytes', + create: () => z.object({ content: z.instanceof(Uint8Array) }), + encode: (input: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => { + const result = input as { readonly content: Uint8Array } + return { content: writeBytes(result.content, ['content']) } + }, + } + const remove = registerStrict(ctx, [{ ...descriptor, result: codec }]) + const strict = await handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + expect(strict).toEqual({ + ok: true, + value: { content: null }, + attachments: [{ path: ['content'], bytes: value.content }], + }) + await remove() + } finally { + await fiber.dispose() + } + }) + + it('owns runtime result projection for SRC methods', async () => { + const { ctx, service } = await setup() + const fiber = ctx.plugin(FakeConnectionService) + await fiber + try { + const handler = rawConnection(ctx).handler! + const call = () => handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + let reads = 0 + const bytes = new Uint8Array([1, 2]) + service.nextResult = { get content() { reads++; return bytes }, get count() { return ++reads } } + await expect(call()).resolves.toEqual({ + ok: true, + value: { content: null, count: 2 }, + attachments: [{ path: ['content'], bytes }], + }) + expect(reads).toBe(2) + + const array: Uint8Array[] = [] + Object.defineProperty(array, 0, { value: bytes }) + service.nextResult = array + await expect(call()).resolves.toEqual({ + ok: true, + value: [null], + attachments: [{ path: [0], bytes }], + }) + + service.nextResult = Object.assign(new Date('2026-01-01T00:00:00Z'), { content: bytes }) + await expect(call()).resolves.toEqual({ ok: true, value: '2026-01-01T00:00:00.000Z' }) + const json = { self: {} as object, toJSON: () => ({ selected: true }) } + json.self = json + service.nextResult = json + await expect(call()).resolves.toEqual({ ok: true, value: { selected: true } }) + let conversions = 0 + service.nextResult = { toJSON(key: string) { conversions++; expect(key).toBe('value'); return this }, count: 5 } + await expect(call()).resolves.toEqual({ ok: true, value: { count: 5 } }) + expect(conversions).toBe(1) + for (const primitive of [1, 'text', true]) { + service.nextResult = Object(primitive) as object + await expect(call()).resolves.toEqual({ ok: true, value: primitive }) + } + service.nextResult = { toJSON: 'ordinary' } + await expect(call()).resolves.toEqual({ ok: true, value: service.nextResult }) + + const protectedName = JSON.parse('{"__proto__":null}') as Record + Object.defineProperty(protectedName, '__proto__', { value: bytes, enumerable: true }) + service.nextResult = protectedName + const protectedResult = await call() + expect(protectedResult).toMatchObject({ + ok: true, + attachments: [{ path: ['__proto__'], bytes }], + }) + if (!protectedResult.ok || typeof protectedResult.value !== 'object' || protectedResult.value === null) { + throw new Error('SRC protected-name result was not projected') + } + expect(Object.hasOwn(protectedResult.value, '__proto__')).toBe(true) + expect(Reflect.get(protectedResult.value, '__proto__')).toBeNull() + + const circular: { next?: object } = {} + circular.next = circular + service.nextResult = circular + await expect(call()).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/internal', message: 'gateway: circular RPC result' }, + }) + } finally { + await fiber.dispose() + } + }) + it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 7611258318..0f0e41d9c1 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: 6e5025b8f5543d810fa951acf5baa32a3c060494 -README.zh.md: c913145966cc982e10c7a533274941c6c52b8b55 +README.md: 0345887e58975b9ef2764144ded20a4f25e8f04d +README.zh.md: 572b83ec37696ee32ba9c3e1e0babdf696afe903 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index 6e5025b8f5..0345887e58 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -25,29 +25,29 @@ Use this package to preview files readable through a Session's filesystem from t ## Use this package -Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. +Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, options, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. | Method | Returns | Purpose | |---|---|---| | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently | -| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded | -| `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | -| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | +| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | Complete file or bounded byte range as `Uint8Array`; optionally resolve from another file's directory | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | | `changes(path)` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries | ### Addressing and paths -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped. +`read`, `readBytes`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readBytes` with `options.baseFile` resolves the relative target `path` from that file's directory, including when either file is outside the workspace. The base file itself accepts an absolute or workspace-relative path. Both files receive the same regular-file checks; an empty target, absolute target, URL, or NUL-containing target is rejected. File results report the absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped. ### Pages `read` returns one line window, never the whole file. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines on the page and defaults to `maxLines`, which it may not exceed — a larger limit, or an offset or limit that is not a positive integer, is a `gateway/bad-request`. Lines end at `\n`, and a final `\n` terminates the last line rather than starting an empty one, so a two-line file has two lines. The page's `text` joins its lines with `\n` and carries no terminator after the last; `eof` is true when the page includes the file's last line, and an offset past the end returns an empty page with `eof` true. Every page also carries the file's `version` from the stat that preceded it, so a consumer can tell a fresh page from a stale one, and `bytes`, the complete file's size when the backend reports it. The service reads the file only up to the first character past the page, so a very large file costs one page of memory per request. -### Byte windows +### Binary reads -`read` pages by lines and never by bytes; a byte window is `readBytes`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as base64 `data`, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. +`read` pages by lines and never by bytes; a byte window is `readBytes(path, { range: { offset?, length? } })`. `options.range.offset` is the 0-based first byte and defaults to 0; `options.range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as `Uint8Array` data, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. + +Pass the required options object as `{}` for a complete file under `maxFileBytes`, or `{ range: {} }` for the default byte window under `maxBytes`. Oversized complete files fail rather than arriving truncated. `baseFile` and `range` can be combined, and windows remain independent of the complete-file cap. The binary Remote uses Connection's multipart response with JSON metadata and raw bytes; the generated Client returns `Uint8Array` without base64 decoding. Caller cancellation reaches the filesystem. The endpoint returns data, not a navigable document. ### File-read and directory checks @@ -62,7 +62,7 @@ File reads and directory listing first use `lstat` to reject a missing path, a f | Field | Default | Meaning | |---|---|---| | `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails | -| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readAll` and `readRelated`; larger files fail with `too-large` | +| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readBytes` without `range`; larger files fail with `too-large` | | `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused | | `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut | @@ -74,7 +74,7 @@ Each failure is one `RemoteError` code with typed details, declared in [`src/typ ### Client file resources -The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through Remote reads. Any UI, including Global components, shares the observation for the same complete address. +The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through the text or binary Remote. Any UI, including Global components, shares the observation for the same complete address. A `session//` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`. @@ -94,13 +94,13 @@ One supervised `changes` stream serves each Session and requested path; consumer Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and directory watching. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. -Complete-file reads delegate size enforcement to `fs.readBytes` and encode the returned bytes as base64 for Remote responses. +Complete-file reads delegate size enforcement to `fs.readBytes` and return raw bytes through the binary Remote. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: target watchers, matching `fs/observed` notifications, and one queue per generation | | [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages | | [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-target change feeds | diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index c913145966..572b83ec37 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -25,29 +25,29 @@ kind: "package-reference" ## 使用本包 -把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 +把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, options, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 | 方法 | 返回 | 用途 | |---|---|---| | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 | -| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 | -| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | -| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | +| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 以 `Uint8Array` 返回完整文件或有界字节范围;可从另一个文件所在目录解析目标 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | | `changes(path)` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 | ### 寻址与路径 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。 +`read`、`readBytes` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readBytes` 携带 `options.baseFile` 时,从基准文件所在目录解析相对目标 `path`,基准文件或目标文件位于工作区外时同样适用。基准文件本身接受绝对路径或工作区相对路径。两个文件执行相同的普通文件检查;目标为空、绝对路径、URL 或含 NUL 时拒绝。文件结果报告文件系统执行环境中的绝对路径。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。 ### 分页 `read` 返回一个行窗口,绝不返回整个文件。`range.offset` 是 1 起算的首行,缺省为 1;`range.limit` 是该页最多的行数,缺省为 `maxLines` 且不得超过它——更大的 limit,或不是正整数的 offset / limit,都是 `gateway/bad-request`。行以 `\n` 结束,末尾的 `\n` 是最后一行的终止符而不是再起一空行,所以两行文件就是两行。页的 `text` 以 `\n` 连接各行,最后一行之后不带终止符;`eof` 在该页含文件最后一行时为 true,offset 越过末尾则返回空页且 `eof` 为 true。每页还带上前置 stat 得到的文件 `version`,消费方据此分辨新页与旧页,以及 `bytes`——后端能报告时的整文件大小。服务只把文件读到该页之后的第一个字符为止,所以再大的文件每次请求也只占一页内存。 -### 字节窗口 +### 二进制读取 -`read` 按行分页,绝不按字节;字节窗口走 `readBytes`。`range.offset` 是 0 起算的首字节,缺省为 0;`range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 base64 的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 +`read` 按行分页,绝不按字节;字节窗口走 `readBytes(path, { range: { offset?, length? } })`。`options.range.offset` 是 0 起算的首字节,缺省为 0;`options.range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 `Uint8Array` 类型的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 + +必填的选项对象传 `{}` 时,在 `maxFileBytes` 上限内读取完整文件;传 `{ range: {} }` 时,在 `maxBytes` 上限内读取默认字节窗口。超大完整文件失败,不截断。`baseFile` 与 `range` 可以组合,窗口始终不受完整文件上限约束。二进制 Remote 使用 Connection 的 multipart 响应传递 JSON 元数据与原始字节;生成的 Client 直接返回 `Uint8Array`,无需 base64 解码。调用方取消会传到文件系统。端点返回数据,不提供可导航的文档。 ### 文件读取与目录检查 @@ -62,7 +62,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 | -| `maxFileBytes` | `33554432`(32 MiB) | `readAll` 和 `readRelated` 的完整文件字节上限(含);更大文件以 `too-large` 失败 | +| `maxFileBytes` | `33554432`(32 MiB) | `readBytes` 未传 `range` 时的完整文件字节上限(含);更大文件以 `too-large` 失败 | | `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 | | `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 | @@ -74,7 +74,7 @@ kind: "package-reference" ### Client 文件资源 -浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,内容另经 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 +浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,文本与字节内容另经对应 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 `session//` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。 @@ -94,13 +94,13 @@ kind: "package-reference" 经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与目录监听。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 -完整文件读取将大小上限检查交给 `fs.readBytes`,并将返回的字节编码为 base64,供 Remote 响应使用。 +完整文件读取将大小上限检查交给 `fs.readBytes`,并通过二进制 Remote 返回原始字节。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:目标监听、匹配的 `fs/observed` 通知与每代流各自的队列 | | [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 | | [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与按目标建立的变更流 | diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 73df728df4..480f133ed8 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -63,6 +63,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index f9807ea534..c5d1542593 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -32,6 +32,7 @@ import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@de import { WorkspaceChangeFeed } from './changes.ts' import type { WorkspaceByteRange, + WorkspaceByteReadOptions, WorkspaceDirectoryEntry, WorkspaceDirectoryListing, WorkspaceFileBytes, @@ -245,38 +246,29 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ @Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, - range: WorkspaceByteRange, + options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise { - const { offset, length } = this.resolveWindow(range, path) - const { target, info } = await this.locateFile(workspaceFileScope, path, signal) - const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal) - const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size - return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } - } - - /** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ - @Remote - async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { - const { target, info } = await this.locateFile(workspaceFileScope, path, signal) + const window = options.range === undefined ? undefined : this.resolveWindow(options.range, path) + const resolved = options.baseFile === undefined ? path : await this.relativePath(workspaceFileScope, options.baseFile, path, signal) + const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal) + if (window !== undefined) { + const { offset, length } = window + const data = await this.ctx.fs.readByteRange(target, window, signal) + const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size + return { ...this.statOf(target, info), offset, data, eof } + } const limit = this.config.maxFileBytes const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') { @@ -284,32 +276,7 @@ export class WorkspaceFiles extends TypertRemoteService { } throw cause }) - return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } - } - - /** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ - @Remote - async readRelated( - workspaceFileScope: WorkspaceFileScope, - path: string, - relativePath: string, - signal: AbortSignal, - ): Promise { - const relative = relativePath.replace(/\\/g, '/') - if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { - throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {}) - } - const { target } = await this.locateFile(workspaceFileScope, path, signal) - const absolute = this.ctx.fs.processPath(target) - const paths = absolute.startsWith('/') ? posix : win32 - return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal) + return { ...this.statOf(target, info), offset: 0, data, eof: true } } /** @@ -365,6 +332,17 @@ export class WorkspaceFiles extends TypertRemoteService { return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal) } + private async relativePath(scope: WorkspaceFileScope, baseFile: string, path: string, signal: AbortSignal): Promise { + const relative = path.replace(/\\/g, '/') + if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { + throw new RemoteError('gateway/bad-request', 'path must be relative when baseFile is provided', {}) + } + const { target } = await this.locateFile(scope, baseFile, signal) + const absolute = this.ctx.fs.processPath(target) + const paths = absolute.startsWith('/') ? posix : win32 + return paths.resolve(paths.dirname(absolute), relative) + } + /** Apply the page defaults and caps here, so the request never carries them implicitly. */ private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } { const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset') diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts index 184767f10e..ea6bc2c086 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -69,15 +69,23 @@ export interface WorkspaceByteRange { readonly length?: number } +/** Target resolution and optional byte range for one binary file read. */ +export interface WorkspaceByteReadOptions { + /** Byte window; omit to read the complete file under the configured `maxFileBytes` cap. */ + readonly range?: WorkspaceByteRange + /** Base file, absolute or workspace-relative; resolve the relative target from its directory. */ + readonly baseFile?: string +} + /** * One byte window of a workspace file as a Client reads it: raw bytes, no text * decoding and no binary rejection. `bytes` is the complete file's size. */ -export interface WorkspaceFileBytes extends WorkspaceFileStat { +export interface WorkspaceFileBytes extends WorkspaceFileStat { /** First byte of the window, as requested. */ readonly offset: number - /** The window's bytes in base64; empty when `offset` lies at or past the file's end. */ - readonly data: string + /** Native file bytes; empty at or past EOF. */ + readonly data: Data /** Whether the window includes the file's last byte. */ readonly eof: boolean } diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts new file mode 100644 index 0000000000..35058d58a1 --- /dev/null +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -0,0 +1,93 @@ +/** Workspace byte reads traverse Gateway lookup and Connection's binary RPC response. */ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import SessionStore from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' +import WorkspaceFiles, { type WorkspaceByteReadOptions } from '../src/index.ts' +import { openWorkspace, type Harness } from './harness.ts' + +let harness: Harness +let rpc: ReturnType + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-binary-rpc-') + const ctx = harness.ctx + await ctx.plugin(SessionStore) + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGateway) + await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) + await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) + ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) + const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) +}) + +afterEach(async () => { + await harness.ctx.fiber.dispose() + await harness.dispose() +}) + +function read(path: string, options: WorkspaceByteReadOptions, signal = new AbortController().signal) { + return rpc.call('/api', 'workspaceFiles/readBytes', { args: { workspaceFileScopeId: harness.scope.sessionId, path, options } }, signal) +} + +it('returns complete binary contents through the ordinary Remote endpoint', async () => { + const data = new Uint8Array([0, 128, 255, 1]) + await writeFile(join(harness.workspace, '字节.bin'), data) + expect(await read('字节.bin', {})).toMatchObject({ ok: true, value: { offset: 0, eof: true, bytes: 4, data } }) + expect(harness.ctx.get('agents')).toBeUndefined() +}) + +it.each([ + [{}, [0, 1, 2, 3, 4, 5, 6, 7], 0, false], + [{ offset: 7, length: 8 }, [7, 8, 9], 7, true], + [{ offset: 10 }, [], 10, true], +])('preserves range %j without reading the whole oversized file', async (range, data, offset, eof) => { + await writeFile(join(harness.workspace, 'base.html'), 'base') + await writeFile(join(harness.workspace, 'large.bin'), new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])) + const complete = vi.spyOn(harness.ctx.fs, 'readBytes') + const window = vi.spyOn(harness.ctx.fs, 'readByteRange') + expect(await read('large.bin', { baseFile: 'base.html', range })).toMatchObject({ + ok: true, value: { offset, eof, bytes: 10, data: new Uint8Array(data) }, + }) + expect(window).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset, length: 8 }, expect.any(AbortSignal)) + expect(complete).not.toHaveBeenCalled() +}) + +it('preserves full-file size failures and Gateway Session lookup failures', async () => { + await writeFile(join(harness.workspace, 'large'), '12345') + expect(await read('large', {})).toMatchObject({ ok: false, error: { code: 'workspace-file/too-large', details: { path: 'large', limit: 4 } } }) + expect(await rpc.call('/api', 'workspaceFiles/readBytes', { args: { workspaceFileScopeId: 'missing', path: 'large', options: {} } })) + .toMatchObject({ ok: false, error: { code: 'gateway/lookup-not-found' } }) +}) + +it.each([{}, { range: {} }])('propagates cancellation into the filesystem for options %j', async (options) => { + await writeFile(join(harness.workspace, 'file'), 'data') + const entered = Promise.withResolvers() + const controller = new AbortController() + const abortable = async (signal: AbortSignal | undefined): Promise => { + if (signal === undefined) throw new Error('read did not receive cancellation') + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true }) + }) + } + if ('range' in options) vi.spyOn(harness.ctx.fs, 'readByteRange').mockImplementation((_target, _range, signal) => abortable(signal)) + else vi.spyOn(harness.ctx.fs, 'readBytes').mockImplementation((_target, signal) => abortable(signal)) + const failure = new Error('tab closed') + const pending = read('file', options, controller.signal) + const rejected = expect(pending).rejects.toBe(failure) + try { + await entered.promise + controller.abort(failure) + await rejected + } finally { + controller.abort(failure) + await rejected + } +}) diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index 21d75bd849..cd5d3b9d73 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -1,6 +1,6 @@ /** Full-file reads retain the ordinary file gates and never return a silently truncated result. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdir, realpath, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsVersion } from '@deepseek-ai/dsh-fs' import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' @@ -10,31 +10,33 @@ let harness: Harness beforeEach(async () => { harness = await openWorkspace('dsh-workspace-files-read-all-') }) afterEach(async () => { await harness.dispose() }) -describe('workspaceFiles.readAll', () => { +describe('workspaceFiles.readBytes — complete files', () => { it('reads the complete bytes independently of the window cap', async () => { const bytes = Buffer.from([0, 255, 1, 2]) await writeFile(join(harness.workspace, 'file.bin'), bytes) - const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(harness.scope, 'file.bin', signal()) - expect(Buffer.from(result.data, 'base64')).toEqual(bytes) + const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readBytes(harness.scope, 'file.bin', {}, signal()) + expect(Buffer.from(result.data)).toEqual(bytes) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 }) }) it('returns an empty complete file', async () => { await writeFile(join(harness.workspace, 'empty'), '') - expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await harness.endpoint().readBytes(harness.scope, 'empty', {}, signal()) + expect(result.data.byteLength).toBe(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => { const path = join(harness[location], 'large') await writeFile(path, 'abcde') - expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal()))) + expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, path, {}, signal()))) .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) }) it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { await writeFile(join(harness.workspace, 'growing'), 'abcde') vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } }) - expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'growing', signal()))).code) + expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, 'growing', {}, signal()))).code) .toBe('workspace-file/too-large') }) @@ -42,17 +44,17 @@ describe('workspaceFiles.readAll', () => { await mkdir(join(harness.workspace, 'directory')) await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readAll(harness.scope, 'missing', signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(files.readAll(harness.scope, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') - const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal()) - expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside') + expect((await failureOf(files.readBytes(harness.scope, 'missing', {}, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readBytes(harness.scope, 'directory', {}, signal()))).code).toBe('workspace-file/not-regular-file') + const outside = await files.readBytes(harness.scope, join(harness.outside, 'outside'), {}, signal()) + expect(Buffer.from(outside.data).toString()).toBe('outside') }) it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( 'preserves non-size filesystem failures: %s', async (failure) => { await writeFile(join(harness.workspace, 'file'), '1234') vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint().readAll(harness.scope, 'file', signal())).rejects.toBe(failure) + await expect(harness.endpoint().readBytes(harness.scope, 'file', {}, signal())).rejects.toBe(failure) }, ) @@ -60,39 +62,39 @@ describe('workspaceFiles.readAll', () => { await writeFile(join(harness.workspace, 'file'), '1234') const failure = { code: 'FS_TOO_LARGE' } vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'file', signal())) + await expect(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, 'file', {}, signal())) .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) }) }) -describe('workspaceFiles.readRelated', () => { +describe('workspaceFiles.readBytes — base file', () => { it('resolves relative paths from the base file directory on the Host', async () => { await mkdir(join(harness.workspace, 'nested')) await writeFile(join(harness.workspace, 'nested/base.txt'), 'base') await writeFile(join(harness.workspace, 'nested/near.txt'), 'near') await writeFile(join(harness.workspace, 'root.txt'), 'root') const files = harness.endpoint() - const near = await files.readRelated(harness.scope, 'nested/base.txt', './near.txt', signal()) - const root = await files.readRelated(harness.scope, 'nested/base.txt', '../root.txt', signal()) - expect(Buffer.from(near.data, 'base64').toString()).toBe('near') - expect(Buffer.from(root.data, 'base64').toString()).toBe('root') - const fromRoot = await files.readRelated(harness.scope, 'root.txt', 'nested\\near.txt', signal()) - expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near') + const near = await files.readBytes(harness.scope, './near.txt', { baseFile: 'nested/base.txt' }, signal()) + const root = await files.readBytes(harness.scope, '../root.txt', { baseFile: 'nested/base.txt' }, signal()) + expect(Buffer.from(near.data).toString()).toBe('near') + expect(Buffer.from(root.data).toString()).toBe('root') + const fromRoot = await files.readBytes(harness.scope, 'nested\\near.txt', { baseFile: 'root.txt' }, signal()) + expect(Buffer.from(fromRoot.data).toString()).toBe('near') }) it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => { - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', path, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, path, { baseFile: 'base' }, signal()))).code).toBe('gateway/bad-request') }) it('resolves related files on either side of the workspace root', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readRelated(harness.scope, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') - const fromOutside = await files.readRelated(harness.scope, join(harness.outside, 'outside'), '../workspace/base', signal()) - const toOutside = await files.readRelated(harness.scope, 'base', '../outside/outside', signal()) - expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base') - expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside') + expect((await failureOf(files.readBytes(harness.scope, 'file', { baseFile: 'missing' }, signal()))).code).toBe('workspace-file/not-found') + const fromOutside = await files.readBytes(harness.scope, '../workspace/base', { baseFile: join(harness.outside, 'outside') }, signal()) + const toOutside = await files.readBytes(harness.scope, '../outside/outside', { baseFile: 'base' }, signal()) + expect(Buffer.from(fromOutside.data).toString()).toBe('base') + expect(Buffer.from(toOutside.data).toString()).toBe('outside') }) it('reads sibling assets beside an outside HTML file with escaped path characters', async () => { @@ -100,8 +102,8 @@ describe('workspaceFiles.readRelated', () => { const base = join(harness.outside, 'space # assets', 'page.html') await writeFile(base, '') await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET') - const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal()) - expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET') + const result = await harness.endpoint().readBytes(harness.scope, './app.js', { baseFile: base }, signal()) + expect(Buffer.from(result.data).toString()).toBe('EXTERNAL_ASSET') }) it.each([ @@ -112,16 +114,41 @@ describe('workspaceFiles.readRelated', () => { await writeFile(join(harness.workspace, 'base'), 'base') const files = harness.endpoint() vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base) - const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true }) + const lstat = harness.ctx.fs.lstat.bind(harness.ctx.fs) + const inspect = vi.spyOn(harness.ctx.fs, 'lstat').mockImplementation((path, options, abort) => + path === 'base' ? lstat(path, options, abort) : Promise.resolve(undefined)) const caller = signal() - await files.readRelated(harness.scope, 'base', './app.js', caller) - expect(read).toHaveBeenCalledWith(harness.scope, expected, caller) + await expect(files.readBytes(harness.scope, './app.js', { baseFile: 'base' }, caller)).rejects.toMatchObject({ code: 'workspace-file/not-found' }) + expect(inspect).toHaveBeenLastCalledWith(expected, { cwd: harness.workspace }, caller) + }) + + it('combines a base file with a bounded range without applying the complete-file cap', async () => { + await mkdir(join(harness.workspace, 'nested')) + await writeFile(join(harness.workspace, 'nested/base.html'), 'base') + await writeFile(join(harness.workspace, 'nested/asset.bin'), new Uint8Array([0, 255, 128, 4, 5, 6])) + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const caller = signal() + const result = await harness.endpoint({ maxBytes: 2, maxFileBytes: 1 }).readBytes( + harness.scope, './asset.bin', { baseFile: 'nested/base.html', range: { offset: 1, length: 2 } }, caller, + ) + expect([...result.data]).toEqual([255, 128]) + expect(result).toMatchObject({ absolutePath: await realpath(join(harness.workspace, 'nested/asset.bin')), offset: 1, eof: false, bytes: 6 }) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 1, length: 2 }, caller) + expect(completeRead).not.toHaveBeenCalled() + }) + + it('checks the base file kind before reading the target', async () => { + await mkdir(join(harness.workspace, 'directory')) + await writeFile(join(harness.workspace, 'target'), 'target') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'target', { baseFile: 'directory' }, signal()))).code) + .toBe('workspace-file/not-regular-file') }) it('rejects a related symlink rather than following it', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.workspace, 'target'), 'target') await symlink('target', join(harness.workspace, 'link')) - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'link', { baseFile: 'base' }, signal()))).code).toBe('workspace-file/not-regular-file') }) }) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts index 2af2ca9927..62cb55a218 100644 --- a/packages/api/workspace-files/tests/read-bytes.spec.ts +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -17,18 +17,16 @@ afterEach(async () => { await harness.dispose() }) -const endpoint = (caps?: { maxBytes?: number }): ReturnType => harness.endpoint(caps) +const endpoint = (caps?: { maxBytes?: number; maxFileBytes?: number }): ReturnType => harness.endpoint(caps) /** 256 bytes, each equal to its offset: the window's content names its position. */ const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i)) -const decode = (data: string): Buffer => Buffer.from(data, 'base64') - describe('workspaceFiles.readBytes — the window', () => { it('returns the whole file as one window by default, with its absolute path, version, and size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data).equals(RAMP)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(RAMP)).toBe(true) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 }) expect(result.absolutePath.endsWith('ramp.bin')).toBe(true) expect(result.version.length).toBeGreaterThan(0) @@ -36,61 +34,68 @@ describe('workspaceFiles.readBytes — the window', () => { it('cuts the requested window and reports that more follows', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal()) - expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 16, length: 8 } }, signal()) + expect([...result.data]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 }) }) - it('reads a window of a file far above the byte cap', async () => { + it('reads only a window of a file above both byte caps', async () => { await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7)) - const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) - expect(decode(result.data)).toHaveLength(1000) + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const caller = signal() + const result = await endpoint({ maxBytes: 1024, maxFileBytes: 4 }).readBytes(harness.scope, 'huge.bin', { range: { offset: 199_000, length: 1024 } }, caller) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 199_000, length: 1024 }, caller) + expect(completeRead).not.toHaveBeenCalled() + expect(result.data).toHaveLength(1000) expect(result).toMatchObject({ eof: true, bytes: 200_000 }) }) it('infers eof from a short window when the backend reports no size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) - const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal()) + const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 0, length: 256 } }, signal()) expect(full.eof).toBe(false) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal()) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 10 } }, signal()) expect(short).toMatchObject({ eof: true }) expect(short.bytes).toBeUndefined() }) it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal()) + const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 248, length: 8 } }, signal()) expect(exact.eof).toBe(true) - expect(decode(exact.data)).toHaveLength(8) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal()) + expect(exact.data).toHaveLength(8) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 100 } }, signal()) expect(short.eof).toBe(true) - expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255]) + expect([...short.data]).toEqual([250, 251, 252, 253, 254, 255]) }) it('returns an empty eof window for an offset at or past the end', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal()) - expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 }) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 300, length: 8 } }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 300, eof: true, bytes: 256 }) }) it('returns an empty eof window for an empty file', async () => { await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0)) - const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal()) - expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await endpoint().readBytes(harness.scope, 'empty.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) - it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => { + it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip as bytes', async () => { const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0]) await writeFile(join(workspace, 'blob.bin'), raw) - const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal()) - expect(decode(result.data).equals(raw)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'blob.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(raw)).toBe(true) }) it('names the version a stat of the same file reports', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal()) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) expect(result.version).toBe(stat.version) }) }) @@ -98,22 +103,22 @@ describe('workspaceFiles.readBytes — the window', () => { describe('workspaceFiles.readBytes — defaults and cap', () => { it('defaults the length to the configured byte cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(64) expect(result.eof).toBe(true) }) it('refuses a window longer than the cap as too-large rather than shortening it', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal())) + const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 65 } }, signal())) expect(failure.code).toBe('workspace-file/too-large') expect(failure.details).toMatchObject({ limit: 64 }) }) it('accepts a window exactly at the cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 64 } }, signal()) + expect(result.data).toHaveLength(64) }) it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => { @@ -124,7 +129,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 }, ] for (const range of ranges) { - const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal())) + const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', { range }, signal())) expect(failure.code).toBe('gateway/bad-request') } }) @@ -133,15 +138,15 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { describe('workspaceFiles.readBytes — the gates it shares with read', () => { it('rejects a directory, a missing path, and an empty path', async () => { await mkdir(join(workspace, 'dir')) - expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') - expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', { range: {} }, signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', { range: {} }, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint().readBytes(harness.scope, '', { range: {} }, signal()))).code).toBe('gateway/bad-request') }) it('reads a bounded byte window outside the workspace', async () => { await writeFile(join(harness.outside, 'sample.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) - expect(decode(result.data)).toEqual(RAMP.subarray(2, 6)) + const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { range: { offset: 2, length: 4 } }, signal()) + expect(result.data).toEqual(RAMP.subarray(2, 6)) expect(result).toMatchObject({ offset: 2, eof: false }) }) }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 4a055c8fd5..b069c9bba2 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -125,6 +125,12 @@ - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' + # The Host inspect providers are process-global: the registry rejects a + # duplicate id, so they register here once and every preset's `tool-cordis` + # row reads them. + - id: cordis-inspect-providers + name: '@deepseek-ai/dsh-tool-cordis/host' + # Ordinary provider for the parsed Web flags. Its plugin-level injection # waits for cmdlineArgs; no launcher metadata or special row kind is needed. - id: web-startup diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index f40797d498..049587c941 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -116,6 +116,7 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index ddf0603df0..fbc282a42b 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 6c49836b0e581666db0df503b175132ed1e6295b -README.zh.md: 5843efd2299526cf00b0c6331668c304d6042f56 +README.md: e50f4fb6838ae27b545bd2b3c4683224f7eb37c9 +README.zh.md: 1bf0733bd660049a5b99a7e7966d65121b0dea3f diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 6c49836b0e..e50f4fb683 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -27,6 +27,8 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. +Unary RPC requests use JSON. A Host handler may return byte attachments that it has already separated from the JSON-compatible result value, with each attachment naming its result-relative path. Connection writes these values as multipart parts. The JSON `metadata` part contains the RPC response envelope with `null` placeholders and an attachment table recording each path, codec, and part identifier. Paths use string keys and numeric array indices; no business field name is reserved. The Client validates the envelope, `rpcId`, attachment table, and parts, then restores each byte value as an `ArrayBuffer`-backed view. Results without attachments, including base64 strings, and failures remain JSON. Logical RPC carriers return decoded native values directly. Connection does not discover binary fields or depend on Typert; the handler that owns a result protocol performs any type-directed or runtime projection before returning. Binary parameters, events, and streamed binary results are unsupported. + The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -42,7 +44,7 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. -Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. +Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway. ## Connection generation diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 5843efd229..1bf0733bd6 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 +一元 RPC 请求使用 JSON。Host handler 可以返回已经从 JSON 兼容结果值中分离的字节附件,每个附件标明其相对于结果的路径。Connection 将这些值写为 multipart 部分。JSON `metadata` 部分包含带 `null` 占位值的 RPC 响应信封,以及记录各路径、codec 和部分标识符的附件表。路径使用字符串键与数字数组下标,不保留任何业务字段名。Client 校验信封、`rpcId`、附件表和各部分,再将每个字节值恢复为以 `ArrayBuffer` 为底层缓冲区的视图。没有附件的结果(包括 base64 字符串)与失败仍使用 JSON。逻辑 RPC 载体直接返回解码后的原生值。Connection 不识别二进制字段,也不依赖 Typert;拥有结果协议的 handler 在返回前执行按类型或按运行时值的投影。不支持二进制参数、事件和二进制流式结果。 + 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -42,7 +44,7 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 -通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 +通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。 ## Connection generation diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index bf4427b41c..c5d5c9b394 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -58,7 +58,11 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea if (!response.ok) { throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) } - const full = parseConnectionResponse(await response.json()) + const mediaType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + const full = mediaType === 'multipart/form-data' + ? await parseBinaryResponse(response) + : parseConnectionResponse(await response.json()) + signal?.throwIfAborted() if (full.rpcId !== rpcId) { throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) } @@ -76,6 +80,64 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea } } +async function parseBinaryResponse(response: Response): Promise> { + const body = await response.formData() + const fields = new Map() + for (const [name, value] of body) { + if (fields.has(name)) throw new TypeError('connection: invalid binary response fields') + fields.set(name, value) + } + const metadata = fields.get('metadata') + fields.delete('metadata') + if (typeof metadata !== 'string') { + throw new TypeError('connection: invalid binary response fields') + } + const envelope: unknown = JSON.parse(metadata) + const full = parseConnectionResponse(envelope) + if (!full.result.ok || !isRecord(envelope) || !Array.isArray(envelope.attachments) || envelope.attachments.length === 0) { + throw new TypeError('connection: invalid binary response result') + } + const root = { value: full.result.value } + for (const attachment of envelope.attachments) { + if (!isRecord(attachment) || attachment.codec !== 'bytes' || typeof attachment.part !== 'string' + || !Array.isArray(attachment.path)) { + throw new TypeError('connection: invalid binary response attachment') + } + const data = fields.get(attachment.part) + fields.delete(attachment.part) + if (!(data instanceof Blob)) { + throw new TypeError('connection: invalid binary response fields') + } + let parent: object = root + let key: string | number = 'value' + for (const segment of attachment.path) { + const value: unknown = Reflect.get(parent, key) + if (typeof value !== 'object' || value === null) { + throw new TypeError('connection: invalid binary response path') + } + if (Array.isArray(value)) { + if (typeof segment !== 'number' || !Number.isSafeInteger(segment) || segment < 0 || segment >= value.length) { + throw new TypeError('connection: invalid binary response path') + } + } else if (typeof segment !== 'string') { + throw new TypeError('connection: invalid binary response path') + } + if (!Object.hasOwn(value, segment)) throw new TypeError('connection: invalid binary response path') + parent = value + key = segment + } + if (Reflect.get(parent, key) !== null) throw new TypeError('connection: invalid binary response placeholder') + Object.defineProperty(parent, key, { + value: new Uint8Array(await data.arrayBuffer()), enumerable: true, writable: true, configurable: true, + }) + } + if (fields.size !== 0) throw new TypeError('connection: invalid binary response fields') + return { + rpcId: full.rpcId, + result: { ok: true, value: root.value }, + } +} + function parseConnectionResponse(value: unknown): { readonly rpcId: RpcIdType readonly result: ConnectionRpcResult diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index ff6042d117..713f53dcea 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -3,7 +3,7 @@ * web carrier; the fetch-shaped handler itself is transport-agnostic). */ -import type { IncomingMessage, ServerResponse } from 'node:http' +import type { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' import type { ConnectionFetchHandler } from './rpc.ts' @@ -13,9 +13,20 @@ import type { ConnectionFetchHandler } from './rpc.ts' * each body in memory, so this cap is also the per-request resident bound. */ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 +interface BridgeServerResponse { + readonly destroyed: boolean + readonly writableEnded: boolean + on(event: 'close', listener: () => void): this + off(event: 'close' | 'drain', listener: () => void): this + once(event: 'close' | 'drain', listener: () => void): this + writeHead(statusCode: number, headers?: Record): unknown + write(chunk: Uint8Array): boolean + end(): unknown +} + /** * Bridge one node:http request to the fetch-shaped handler (client close - * aborts; response bodies stream out chunk by chunk). + * aborts; response writes respect backpressure and stop on disconnect). * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. @@ -23,7 +34,7 @@ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 */ export async function bridge( req: IncomingMessage, - res: ServerResponse, + res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { @@ -90,11 +101,13 @@ export async function bridge( return } for await (const chunk of response.body) { + // Drain without writing after disconnect: cancelling Node multipart bodies + // can race their producer and reject with ERR_INVALID_STATE. + if (abort.signal.aborted) continue // Backpressure: a false return means the socket buffer is full — wait for drain // instead of buffering unboundedly (slow or suspended consumers). 'close' also - // resolves so a mid-wait disconnect can't park this loop forever; the close - // handler above aborts the handler stream, which then ends the iteration. - if (!res.write(chunk)) { + // resolves so a mid-wait disconnect cannot park this loop forever. + if (!res.write(chunk) && !res.destroyed) { await new Promise((resolve) => { const done = (): void => { res.off('drain', done) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c76ce937c8..647174f743 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -21,8 +21,10 @@ export type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, + ConnectionRpcAttachment, ConnectionRpcFailure, ConnectionRpcHandler, + ConnectionRpcHandlerResult, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index ba6f2736d6..0f98bc1f19 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -292,9 +292,23 @@ function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response return fullResponse(rpcId, { ok: false, error }) } -function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult): Response { - const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } - return Response.json(body) +function fullResponse(rpcId: RpcIdType, result: Awaited>): Response { + if (!result.ok) { + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } + return Response.json(body) + } + const { attachments, ...success } = result + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result: success } + if (attachments === undefined || attachments.length === 0) return Response.json(body) + const parts = new FormData() + const attachmentMetadata = attachments.map((attachment, index) => { + const part = `bytes-${index}` + // FileSystem bytes may have SharedArrayBuffer backing, which BlobPart excludes. + parts.set(part, new Blob([new Uint8Array(attachment.bytes)])) + return { path: [...attachment.path], codec: 'bytes' as const, part } + }) + parts.set('metadata', JSON.stringify({ ...body, attachments: attachmentMetadata })) + return new Response(parts) } function assertChannel(channel: string): void { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e77562acfd..0b8755bf71 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -27,6 +27,24 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } +/** One binary value separated from a successful RPC result before transport framing. */ +export interface ConnectionRpcAttachment { + /** Result-relative path occupied by the attachment's `null` placeholder. */ + readonly path: readonly (string | number)[] + /** Byte view carried outside the JSON response metadata. */ + readonly bytes: Uint8Array +} + +/** Successful or failed handler result ready for Connection transport framing. */ +export type ConnectionRpcHandlerResult = + | { + readonly ok: true + readonly value: unknown + /** Binary fields already projected by the handler that owns the result protocol. */ + readonly attachments?: readonly ConnectionRpcAttachment[] + } + | { readonly ok: false; readonly error: ConnectionRpcFailure } + /** Historical short name for a generic Connection result. */ export type RpcResult = ConnectionRpcResult @@ -111,7 +129,7 @@ export type ConnectionRpcHandler = ( payload: unknown, signal: AbortSignal, peer: PeerScope, -) => Promise> +) => Promise /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean diff --git a/packages/client/connection/tests/binary-rpc.host.spec.ts b/packages/client/connection/tests/binary-rpc.host.spec.ts new file mode 100644 index 0000000000..05ec270545 --- /dev/null +++ b/packages/client/connection/tests/binary-rpc.host.spec.ts @@ -0,0 +1,285 @@ +import { request } from 'node:http' +import { gunzipSync } from 'node:zlib' +import { Context } from '@deepseek-ai/cordis' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { describe, expect, it } from 'vitest' +import type { BrowserAuth } from '../src/browser-auth.ts' +import { createWebConnectionRpc } from '../src/client/rpc.ts' +import { bridge } from '../src/http-bridge.ts' +import type { ConnectionRpcHandlerResult, ConnectionRpcResult } from '../src/rpc.ts' +import { HostConnectionService } from '../src/rpc-host.ts' + +function binaryResponse(rpcId: string, mutate: (parts: FormData) => void = () => {}): Response { + const parts = new FormData() + parts.set('metadata', JSON.stringify({ + type: 'server-response', rpcId, result: { ok: true, value: { offset: 7, data: null } }, + attachments: [{ path: ['data'], codec: 'bytes', part: 'bytes-0' }], + })) + parts.set('bytes-0', new Blob([new Uint8Array([0, 128, 255])])) + mutate(parts) + return new Response(parts) +} + +describe('Connection binary RPC', () => { + it.each(['gzip', 'none'] as const)('preserves %s configuration through the HTTP bridge', async (compression) => { + const ctx = new Context() + try { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0, compression }) + await ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + const connection = ctx.get('connection') as HostConnectionService + const data = new Uint8Array(1024 * 1024).fill(65) + data.set([0, 128, 255]) + connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => ({ + ok: true, + value: { data: null, bytes: data.length }, + attachments: [{ path: ['data'], bytes: data }], + })) + const handler = connection.createSharedFetchHandler('/api') + ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler) }) + let transferred = 0 + let encoding: string | undefined + const rpc = createWebConnectionRpc((path, init) => new Promise((resolve, reject) => { + const req = request(new URL(path, `http://127.0.0.1:${ctx.webServer.port}`), { + method: init.method, + headers: { ...Object.fromEntries(new Headers(init.headers)), 'accept-encoding': 'gzip' }, + }, (response) => { + void (async () => { + const chunks: Buffer[] = [] + for await (const chunk of response) chunks.push(chunk as Buffer) + const body = Buffer.concat(chunks) + transferred = body.length + encoding = response.headers['content-encoding'] + return new Response(encoding === 'gzip' ? gunzipSync(body) : body, { + status: response.statusCode!, + headers: { 'content-type': response.headers['content-type']! }, + }) + })().then(resolve, reject) + }) + req.once('error', reject) + req.end(init.body as string) + })) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { data, bytes: data.length } }) + if (compression === 'gzip') { + expect(encoding).toBe('gzip') + expect(transferred).toBeLessThan(data.length / 10) + } else { + expect(encoding).toBeUndefined() + expect(transferred).toBeGreaterThan(data.length) + } + } finally { + await ctx.fiber.dispose() + } + }) + + it('roundtrips raw bytes and metadata on the existing channel while JSON results and errors stay JSON', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + const connection = ctx.get('connection') as HostConnectionService + const byteValues = [ + new Uint8Array([9, 0, 128, 255, 9]).subarray(1, 4), + new Uint8Array(new SharedArrayBuffer(3)).fill(255), + new Uint8Array(), + ] + const cases: readonly { + readonly sent: ConnectionRpcHandlerResult + readonly expected: ConnectionRpcResult + }[] = [ + ...byteValues.map((data, index) => ({ + sent: { + ok: true as const, + value: { data: null, offset: index === 0 ? 7 : 0, eof: index !== 0, bytes: index === 0 ? 42 : data.length }, + attachments: [{ path: ['data'], bytes: data }], + }, + expected: { + ok: true as const, + value: { data, offset: index === 0 ? 7 : 0, eof: index !== 0, bytes: index === 0 ? 42 : data.length }, + }, + })), + { sent: { ok: true, value: { data: 'AID/', encoding: 'base64' } }, expected: { ok: true, value: { data: 'AID/', encoding: 'base64' } } }, + { sent: { ok: true, value: { count: 4 } }, expected: { ok: true, value: { count: 4 } } }, + { sent: { ok: true, value: null }, expected: { ok: true, value: null } }, + { sent: { ok: true, value: 'plain' }, expected: { ok: true, value: 'plain' } }, + { + sent: { ok: false, error: { code: 'fixture/denied', message: 'denied', details: { path: 'private' } } }, + expected: { ok: false, error: { code: 'fixture/denied', message: 'denied', details: { path: 'private' } } }, + }, + ] + let next: ConnectionRpcHandlerResult = cases[0]!.sent + connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => next) + const shared = connection.createSharedFetchHandler('/api') + const mediaTypes: (string | null)[] = [] + const rpc = createWebConnectionRpc(async (url, init) => { + const response = await shared.fetch(new Request(new URL(url, 'http://host'), init)) + mediaTypes.push(response.headers.get('content-type')) + return response + }) + for (const { sent, expected } of cases) { + next = sent + const received = await rpc.call('/api', 'fixture/read', { args: {} }) + expect(received).toEqual(expected) + if (received.ok && typeof received.value === 'object' && received.value !== null + && 'data' in received.value && received.value.data instanceof Uint8Array) { + expect(received.value.data.buffer).toBeInstanceOf(ArrayBuffer) + } + } + expect(mediaTypes.slice(0, 3).every(type => type?.startsWith('multipart/form-data;'))).toBe(true) + expect(mediaTypes.slice(3)).toEqual(Array(5).fill('application/json')) + } finally { + await fiber.dispose() + } + }) + + it('frames attachments already projected by the result owner', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + const connection = ctx.get('connection') as HostConnectionService + const content = Buffer.from([9, 0, 128, 255, 9]).subarray(1, 4) + connection.rpc.intercept('/api', () => true, async () => ({ + ok: true, + value: { content: null, metadata: { count: 1 } }, + attachments: [{ path: ['content'], bytes: content }], + })) + const handler = connection.createSharedFetchHandler('/api') + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ + ok: true, value: { content: new Uint8Array([0, 128, 255]), metadata: { count: 1 } }, + }) + } finally { + await fiber.dispose() + } + }) + + it.each([ + ['missing bytes', (p: FormData) => { p.delete('bytes-0') }], + ['text bytes', (p: FormData) => { p.set('bytes-0', 'AID/') }], + ['missing metadata', (p: FormData) => { p.delete('metadata') }], + ['file metadata', (p: FormData) => { p.set('metadata', new Blob(['{}'])) }], + ['duplicate metadata', (p: FormData) => { p.append('metadata', '{}') }], + ['duplicate bytes', (p: FormData) => { p.append('bytes-0', new Blob()) }], + ['extra part', (p: FormData) => { p.set('extra', 'unclaimed') }], + ['invalid JSON', (p: FormData) => { p.set('metadata', '{') }], + ['invalid envelope', (p: FormData) => { p.set('metadata', '{}') }], + ...[ + { ok: false, error: { code: 'fixture/error', message: 'failed', details: {} } }, + { ok: true, value: null }, + { ok: true, value: [] }, + { ok: true, value: { data: 'duplicate' } }, + ].map(result => ['invalid result', (p: FormData) => { p.set('metadata', JSON.stringify({ + type: 'server-response', rpcId: 'fixture', result, + })) }] as const), + ] satisfies readonly (readonly [string, (parts: FormData) => void])[])('rejects malformed multipart: %s', async (_name, mutate) => { + const rpc = createWebConnectionRpc(async (_url, init) => { + const message = JSON.parse(init.body as string) as { rpcId: string } + return binaryResponse(message.rpcId, mutate) + }) + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow(/invalid binary response|invalid server-response|JSON/) + }) + + it('rejects a binary response for another request', async () => { + const rpc = createWebConnectionRpc(async () => binaryResponse('unrelated')) + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('rpcId mismatch') + }) + + it('roundtrips nested, optional and root attachment paths without reserving field names', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + const data = new Uint8Array([0, 128, 255]) + const second = Buffer.from([9, 2, 3, 9]).subarray(1, 3) + let next: ConnectionRpcHandlerResult = { + ok: true, + value: { + files: [{ name: 'a.png', content: null }, { name: 'b.png', content: null }, { name: 'empty' }], + 'dots./[]': [null, null, [null]], + metadata: { attachments: { content: null } }, + ...JSON.parse('{"__proto__":null,"constructor":null}') as object, + }, + attachments: [ + { path: ['files', 0, 'content'], bytes: data }, + { path: ['files', 1, 'content'], bytes: second }, + { path: ['dots./[]', 0], bytes: new Uint8Array() }, + { path: ['dots./[]', 2, 0], bytes: data }, + { path: ['metadata', 'attachments', 'content'], bytes: data }, + { path: ['__proto__'], bytes: data }, + ], + } + const connection = ctx.get('connection') as HostConnectionService + connection.rpc.intercept('/api', () => true, async () => next) + const handler = connection.createSharedFetchHandler('/api') + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const expected = { + files: [{ name: 'a.png', content: data }, { name: 'b.png', content: new Uint8Array([2, 3]) }, { name: 'empty' }], + 'dots./[]': [new Uint8Array(), null, [data]], + metadata: { attachments: { content: data } }, + ...JSON.parse('{"__proto__":null,"constructor":null}') as object, + } + Object.defineProperty(expected, '__proto__', { value: data, enumerable: true }) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: expected }) + for (const bytes of [data, Buffer.from([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) { + next = { ok: true, value: null, attachments: [{ path: [], bytes }] } + const response = await rpc.call('/api', 'fixture/read', {}) + expect(response).toEqual({ ok: true, value: new Uint8Array(bytes) }) + if (!response.ok || !(response.value instanceof Uint8Array)) throw new Error('expected root bytes') + expect(response.value.buffer).toBeInstanceOf(ArrayBuffer) + expect(Object.isFrozen(response.value)).toBe(false) + } + next = { + ok: true, + value: [null, [null]], + attachments: [{ path: [0], bytes: data }, { path: [1, 0], bytes: data }], + } + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: [data, [data]] }) + } finally { + await fiber.dispose() + } + }) + + it.each([ + ['absent table', { attachments: undefined }], + ['non-array table', { attachments: {} }], + ['empty table', { attachments: [] }], + ...[ + null, {}, { path: ['data'], codec: 'numpy', part: 'bytes-0' }, + { path: ['data'], codec: 'bytes', part: 0 }, + { path: ['data'], codec: 'bytes', part: 'metadata' }, + { path: 'data', codec: 'bytes', part: 'bytes-0' }, + ...[['missing'], ['offset', 'child'], ['constructor', 'prototype'], [0], ['files', '0'], ['files', -1], ['files', 0.5], ['files', 1], ['files', false], ['files', null]].map(path => ({ path, codec: 'bytes', part: 'bytes-0' })), + ].map(attachment => ['invalid attachment', { attachments: [attachment] }] as const), + ['duplicate part', { attachments: [0, 1].map(() => ({ path: ['data'], codec: 'bytes', part: 'bytes-0' })) }], + ['duplicate path', { attachments: [0, 1].map(i => ({ path: ['data'], codec: 'bytes', part: `bytes-${i}` })) }], + ['overlapping path', { attachments: [ + { path: ['data'], codec: 'bytes', part: 'bytes-0' }, + { path: ['data', '0'], codec: 'bytes', part: 'bytes-1' }, + ] }], + ['occupied placeholder', { result: { ok: true, value: { data: 'AID/' } } }], + ] satisfies readonly (readonly [string, Record])[])('rejects malformed attachment metadata: %s', async (_name, overrides) => { + const rpc = createWebConnectionRpc(async (_url, init) => { + const { rpcId } = JSON.parse(init.body as string) as { rpcId: string } + return binaryResponse(rpcId, (parts) => { + const metadata = JSON.parse(parts.get('metadata') as string) as Record + parts.set('metadata', JSON.stringify({ ...metadata, result: { ok: true, value: { data: null, offset: 7, files: [null] } }, ...overrides })) + if (_name === 'duplicate path' || _name === 'overlapping path') parts.set('bytes-1', new Blob()) + }) + }) + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('invalid binary response') + }) + + it('rejects truncated multipart and late bytes after caller cancellation', async () => { + const broken = createWebConnectionRpc(async () => new Response('truncated', { + headers: { 'content-type': 'multipart/form-data; boundary=fixture' }, + })) + await expect(broken.call('/api', 'fixture/read', {})).rejects.toThrow() + const abort = new AbortController() + const rpc = createWebConnectionRpc(async (_url, init) => { + const message = JSON.parse(init.body as string) as { rpcId: string } + abort.abort(new Error('caller left')) + return binaryResponse(message.rpcId) + }) + await expect(rpc.call('/api', 'fixture/read', {}, abort.signal)).rejects.toThrow('caller left') + }) +}) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 9dc6df8405..e9fa9df364 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -75,6 +75,73 @@ describe('HTTP bridge abort', () => { expect(carrierSignal?.aborted).toBe(true) }) + it.each(['write', 'backpressure'])('finishes a multipart response without further writes when the client closes during %s', async (phase) => { + const request = Readable.from([]) as IncomingMessage + Object.assign(request, { url: '/api/workspaceFiles/readBytes', method: 'GET', headers: {} }) + const body = new FormData() + body.set('metadata', '{}') + body.set('data', new Blob([new Uint8Array(16 * 1024 * 1024)])) + let writes = 0 + let cleaningUp = false + const response = Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + writeHead() { return this }, + write() { + writes++ + if (writes === 1) { + const close = (): void => { response.destroyed = true; response.emit('close') } + if (phase === 'write') close() + else queueMicrotask(close) + } + return cleaningUp + }, + end() { this.writableEnded = true; return this }, + }) + let settled = false + const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', + fetch: async () => new Response(body), + }).then(() => { settled = true }) + try { + await expect.poll(() => settled).toBe(true) + expect(writes).toBe(1) + expect(response.listenerCount('drain')).toBe(0) + } finally { + // Release a regressed bridge parked after the one close event. + cleaningUp = true + response.emit('drain') + await pending + } + }) + + it('discards bytes returned after the client disconnects', async () => { + const request = Readable.from([]) as IncomingMessage + Object.assign(request, { url: '/api/workspaceFiles/readBytes', method: 'GET', headers: {} }) + const response = Object.assign(new EventEmitter(), { + destroyed: false, + writableEnded: false, + writeHead() { return this }, + write() { throw new Error('a disconnected response must not receive bytes') }, + end() { this.writableEnded = true; return this }, + }) + const started = Promise.withResolvers() + let controller!: ReadableStreamDefaultController + const body = new ReadableStream({ + start(value) { controller = value }, + pull() { started.resolve(undefined) }, + }) + const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', + fetch: async () => new Response(body), + }) + await started.promise + response.emit('close') + controller.enqueue(Uint8Array.of(1)) + controller.close() + await pending + }) + it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => { const request = new Readable({ read() {} }) as IncomingMessage Object.assign(request, { diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index c291ff868d..66d9844f17 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -9,6 +9,8 @@ "src/api-path.ts", "src/api-request-trust.ts", "src/browser-auth.ts", + "src/client/random-uuid.ts", + "src/client/rpc.ts", "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 5ce716a2ae..0fad5492e0 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e -README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 +README.md: 5a3e697189936a79df19ff9b976f06e0dd6191fb +README.zh.md: 4168650770f42e7b94146041f37ffc440a5ec52e diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 0765068c73..5a3e697189 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -39,7 +39,7 @@ Trajectory attachment rows request 48px square thumbnails that contain the compl ### Drop overlay -While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject. +While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them. ----- diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 5976acfda5..4168650770 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -39,7 +39,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪 ### 拖放遮罩 -文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态——是否接受由持有方的文档级监听器决定。 +文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 ----- diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index e04cbd43d0..2f3da69372 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -1,6 +1,24 @@ /** Document drag-and-drop listeners owned by one mounted attachment view. */ import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** + * Members of a drop that are directories. The `File` a directory drop yields + * is indistinguishable from an empty file, so the entry API is the only + * source of that fact; browsers without it report no directories. + */ +function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]): ReadonlySet { + const directories = new Set() + let fileIndex = 0 + for (const item of dataTransfer.items) { + if (item.kind !== 'file') continue + const file = files[fileIndex++] + if (typeof item.webkitGetAsEntry !== 'function') continue + if (item.webkitGetAsEntry()?.isDirectory !== true) continue + if (file !== undefined) directories.add(file) + } + return directories +} + /** * Install one attachment view's file-drop listeners. * @param canAcceptDrop - whether this view accepts the dropped files. @@ -49,7 +67,10 @@ export function installDocumentDropEvents( if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddFiles([...dataTransfer.files]) + if (canAcceptDrop) { + const files = [...dataTransfer.files] + onAddFiles(files, droppedDirectories(dataTransfer, files)) + } } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index c0d0920c52..057e29dc9c 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -103,20 +103,49 @@ describe('ComposerAttachments', () => { expect(view.queryByRole('status')).toBeNull() const image = attachment('dropped').file - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { types: ['Files'], files: [image], items: [], dropEffect: 'none' } expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加') expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB') expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) - expect(onAddFiles).toHaveBeenCalledWith([image]) + expect(onAddFiles).toHaveBeenCalledWith([image], new Set()) expect(view.queryByRole('status')).toBeNull() }) + it('reports dropped directories from the entry API beside the dropped files', () => { + const onAddFiles = vi.fn() + render() + const folder = new File([], 'project') + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + const entry = (isDirectory: boolean | null, file: File) => ({ + kind: 'file', getAsFile: () => new File([], file.name), webkitGetAsEntry: () => (isDirectory === null ? null : { isDirectory }), + }) + fireEvent.drop(document.body, { + dataTransfer: { + types: ['Files'], + files: [folder, note], + items: [entry(true, folder), entry(false, note), { kind: 'string', getAsFile: () => null }], + dropEffect: 'none', + }, + }) + expect(onAddFiles).toHaveBeenCalledWith([folder, note], new Set([folder])) + // An entry API that answers nothing, or a browser without it, reports no directories. + fireEvent.drop(document.body, { + dataTransfer: { + types: ['Files'], + files: [note], + items: [entry(null, note), { kind: 'file', getAsFile: () => note }, entry(true, folder)], + dropEffect: 'none', + }, + }) + expect(onAddFiles).toHaveBeenLastCalledWith([note], new Set()) + }) + it('tracks nested file drags and clears an aborted drag', () => { const view = render() - const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' } + const dataTransfer = { types: ['Files'], files: [], items: [], dropEffect: 'none' } fireEvent.dragLeave(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' }, }) @@ -144,7 +173,7 @@ describe('ComposerAttachments', () => { const onAddFiles = vi.fn() const view = render() const image = attachment('blocked').file - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } + const dataTransfer = { types: ['Files'], files: [image], items: [], dropEffect: 'copy' } fireEvent.dragEnter(document.body, { dataTransfer }) expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片') fireEvent.dragOver(document.body, { dataTransfer }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e655890c11..2f52a3e505 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: b1bfea3d4ccef1076ed337a8ec229b64c9044ec0 -README.zh.md: d529286f2609b02aad1ed26f69aa54d9ef9bc710 +README.md: 31b35ef7460c9fecd119e32ae5f696f55585a726 +README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b1bfea3d4c..31b35ef746 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -62,7 +62,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d529286f26..1fb648bf95 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -62,7 +62,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8a92b63714..e5b6bcdf2b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -80,6 +80,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9004f8fd62..760f7ac07b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -18,8 +18,11 @@ import type { ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' +import type { ReferenceInsert } from './contract/draft-editor.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' -import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' +import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' +import { relativizeToCwd, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import { ConversationController, UnsupportedImageMediaTypeError, isImageMediaType } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './contract/composer-blocks.ts' @@ -87,6 +90,21 @@ const ABSENT_FILE_UPLOADS = { subscribe: () => () => {}, } +/** + * Browser-shell bridge reporting the harness-host path of a picked file. The + * Desktop preload exposes it on the application document; a served Web page + * has none, so every non-image file uploads there. + */ +interface HostPathBridge { + /** Absolute harness-host path of one picked file, or empty when the shell has none for it. */ + pathFor(file: File): string +} + +/** The shell-installed bridge, when this document runs inside the Desktop application. */ +function hostPathBridge(): HostPathBridge | undefined { + return (globalThis as { __DSH_HOST_PATHS__?: HostPathBridge }).__DSH_HOST_PATHS__ +} + interface WorkspaceNavigation { openSession(sessionId: SessionId): void openWorkspace( @@ -393,14 +411,41 @@ export function apply(ctx: Context, config: Config = Config({})): void { const conversation = concreteConversation(ctx) const shell = inputHub.shell(sessionId) const inputTriggers = inputHub.inputTriggers(sessionId) + const bridge = hostPathBridge() return { keyboard: shell, - addFiles: (files) => { + addFiles: (files, directories = new Set()) => { if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') + if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') { + return t('attachment.dropBlocked') + } + const uploads: File[] = [] + const references: ReferenceInsert[] = [] + const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd + for (const file of files) { + const directory = directories.has(file) + if (bridge === undefined && directory) return t('attachment.directoryDesktopOnly') + const path = bridge?.pathFor(file) ?? '' + if (directory && path === '') return t('attachment.pathUnavailable') + if (path === '' || (!directory && isImageMediaType(file.type))) { + uploads.push(file) + continue + } + const relative = relativizeToCwd(path, cwd) + // A completed directory chip needs closed quotes; the directory grammar keeps them open for drill. + const mention = formatFileMention({ path: directory ? `${relative}/` : relative, kind: 'file' }, false) + if (mention === undefined) return t('attachment.pathUnsupported') + const label = workspaceTitleOf(path) || file.name + references.push({ + source: 'reference', ref: mention, label: directory ? `${label}/` : label, + appearance: directory ? 'folder' : 'file', clipboardText: mention, + }) + } try { - const drafts = conversation.createDrafts(sessionId, files) - if (!shell.addAttachments(drafts.map(draft => draft.id))) { + const drafts = conversation.createDrafts(sessionId, uploads) + if (!shell.addFiles(references, drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) + return t('attachment.dropBlocked') } return null } catch (error: unknown) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5f57d69b5d..00f8db03b8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -60,8 +60,12 @@ export interface ComposerAttachmentsOwnerProps { attachments: readonly ComposerAttachment[] /** Whether a document-level file drop may add attachments now. */ canAcceptDrop: boolean - /** Add one dropped batch through the composer's validation path. */ - onAddFiles: (files: readonly File[]) => void + /** + * Add one dropped batch through the composer's validation path. + * @param files - dropped, pasted, or picked browser files in source order. + * @param directories - members of `files` the drop source identified as directories. + */ + onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void /** Remove one draft attachment through the Conversation service. */ onRemoveAttachment: (id: DraftAttachmentId) => void /** Current per-draft upload states for file-kind attachments. */ @@ -368,7 +372,14 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { keyboard: ComposerKeyboard | undefined - addFiles: ((files: readonly File[]) => string | null) | undefined + /** + * Register one picked batch; resolves to the rejection copy or null. Where + * the browser shell reports host paths (the Desktop application), files + * and folders with a real path become `@path` references in the draft + * instead of uploads; `directories` names the members the drop source + * identified as directories. + */ + addFiles: ((files: readonly File[], directories?: ReadonlySet) => string | null) | undefined removeAttachment: ((id: DraftAttachmentId) => void) | undefined resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined /** Restart one failed file upload; absent without a session. */ diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 42c217caa0..7291f2c25a 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -34,8 +34,12 @@ export interface ComposerKeymapHandlers { canSubmit(): boolean /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ submit(accelerated: boolean): void - /** Pasted files (image intake). */ - intakeFiles(files: readonly File[]): void + /** + * Pasted files with directory metadata supplied by the clipboard entry API. + * @param files - browser files in clipboard order. + * @param directories - known directory members; absent when no entry identifies a directory. + */ + intakeFiles(files: readonly File[], directories?: ReadonlySet): void /** Pasted plain text (sanitized insertion through the shell). */ pasteText(text: string): void } @@ -154,11 +158,18 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer // deliver clipboardData on plain events. const clipboardData = (event as ClipboardEvent).clipboardData ?? null if (clipboardData === null) return false - const files = Array.from(clipboardData.items) - .filter(item => item.kind === 'file') - .map(item => item.getAsFile()) - .filter((file): file is File => file !== null) - if (files.length > 0) handlers.intakeFiles(files) + const files: File[] = [] + const directories = new Set() + for (const item of clipboardData.items) { + if (item.kind !== 'file') continue + const file = item.getAsFile() + if (file === null) continue + files.push(file) + if (typeof item.webkitGetAsEntry === 'function' && item.webkitGetAsEntry()?.isDirectory === true) { + directories.add(file) + } + } + if (files.length > 0) handlers.intakeFiles(files, directories.size === 0 ? undefined : directories) const text = clipboardData.getData('text/plain') if (text === '') { if (files.length === 0) return false diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index ca6047c4ec..f59a6d947d 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -241,6 +241,25 @@ export class DraftEditorRuntime { return applied } + /** + * Insert an ordered file-reference batch after the live selection without deleting it. + * @param references - validated references in source order. + * @returns whether the live insertion position accepted the batch. + */ + insertFileReferences(references: readonly ReferenceInsert[]): boolean { + if (references.length === 0) return true + let applied = false + this.applyEdit(() => { + const projection = $projectComposer(key => this.occurrenceIdOf(key)) + const at = projection.selection?.end ?? projection.detectText.length + const before = projection.detectText.slice(0, at) + const nodes = references.flatMap(ref => [$createReferenceChipNode(ref), $createTextNode(' ')]) + if (before !== '' && !/\s$/u.test(before)) nodes.unshift($createTextNode(' ')) + applied = $replaceDetectSpanWithNodes({ start: at, end: at }, nodes) + }, PASTE_TAG) + return applied + } + /** Refresh claim-token decoration after the model's claim changes. */ refreshClaimDecoration(): void { refreshClaimDecoration(this.editor) diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 8dcd3d8c63..89218a887c 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -14,7 +14,7 @@ interface DraftViewGate { running: boolean steeringAvailable: boolean busyEnter: BusyEnterBehavior - intakeFiles: (files: readonly File[]) => void + intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void uploadsPending: boolean showToast: (text: string) => void t: ComposerBarProps['t'] @@ -136,7 +136,7 @@ export function installDraftKeymap( g.steeringAvailable, )) }, - intakeFiles: (files) => { gate.current.intakeFiles(files) }, + intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) }, pasteText: (text) => { if (gate.current.machineBusy || gate.current.locked) return keyboard.paste(text) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 8931d1c4d0..3cd956bf76 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -221,6 +221,20 @@ export class SessionInputShell implements SessionInput { return true } + /** + * Add validated file references and attachment ids while admission is editable. + * @param references - reference chips in source order. + * @param ids - newly allocated attachment ids. + * @returns false when admission is locked or the editor refuses the insertion. + */ + addFiles(references: readonly ReferenceInsert[], ids: readonly DraftAttachmentId[]): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + if (!this.draftEditor.insertFileReferences(references)) return false + this.attachmentIds = [...this.attachmentIds, ...ids] + this.publish() + return true + } + /** * Remove one attachment id from this draft. Busy admission phases refuse, like * {@link addAttachments}: a removal landing while a command submit serializes diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c164dc8535..8ddad82052 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -31,6 +31,9 @@ export const zh = { 'attachment.dropTitle': '文件或图片拖动到此处即可添加', 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', 'attachment.dropBlocked': '当前无法添加文件或图片', + 'attachment.directoryDesktopOnly': '只有桌面端支持添加文件夹,浏览器里请添加单个文件', + 'attachment.pathUnavailable': '无法获取文件夹路径,请重新拖入', + 'attachment.pathUnsupported': '路径含有无法引用的字符,请改名后再试', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', @@ -382,6 +385,9 @@ export const en = { 'attachment.dropTitle': 'Drag files or images here to add them', 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', 'attachment.dropBlocked': 'Files and images cannot be added right now', + 'attachment.directoryDesktopOnly': 'Folders can only be added in the desktop app; add individual files in the browser', + 'attachment.pathUnavailable': 'Could not obtain the folder path; drag it in again', + 'attachment.pathUnsupported': 'The path contains characters a reference cannot carry; rename it and try again', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 367647658e..2ef2309ccf 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -594,8 +594,12 @@ function imageMediaType(value: string): ImageMediaType { } } -/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */ -function isImageMediaType(value: string): boolean { +/** + * Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). + * @param value - the browser's declared MIME type. + * @returns whether the file is an accepted raster image. + */ +export function isImageMediaType(value: string): boolean { return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5b045fb05c..7016ea8f9d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -200,7 +200,7 @@ export const InputBar = memo(function InputBar({ // client-side size or count limit and upload as soon as they are picked. // The host enforces the same image limits at submit for callers that bypass // this composer. - const intakeFiles = useCallback((files: readonly File[]): void => { + const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { if (subagent !== null || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { @@ -219,7 +219,7 @@ export const InputBar = memo(function InputBar({ return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) } } - return addFiles(files) + return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) }, [subagent, addFiles, attachments, imageLimits, showToast, t]) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index e90a11a163..db7eb068ba 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,4 +1,8 @@ // @vitest-environment jsdom +import { render, cleanup } from '@testing-library/react' +import { $getRoot, $isTextNode, PASTE_COMMAND } from 'lexical' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' +import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' @@ -399,6 +403,90 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('cites shell-named files and folders as @ references and refuses folders without the shell bridge', async () => { + const browser = await bench() + const folder = new File([], 'project') + expect(browser.composerApi(ROOT).addFiles?.([folder], new Set([folder]))) + .toBe('只有桌面端支持添加文件夹,浏览器里请添加单个文件') + expect(browser.inputApi(ROOT).state.getSnapshot().attachmentIds).toEqual([]) + await browser.runtime.dispose() + + const paths = new Map([['project', '/Users/me/my project'], ['notes.md', '/Users/me/notes.md'], ['shot.png', '/Users/me/shot.png']]) + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => paths.get(file.name) ?? '' }) + try { + const desktop = await bench() + const composer = desktop.composerApi(ROOT) + const { state } = desktop.inputApi(ROOT) + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + const shot = new File([Uint8Array.of(2)], 'shot.png', { type: 'image/png' }) + const pasted = new File([Uint8Array.of(3)], 'pasted.bin', { type: 'application/octet-stream' }) + expect(composer.addFiles?.([folder, note, shot, pasted], new Set([folder]))).toBeNull() + // The folder and the file became references in the draft; the image and the pathless bytes stayed drafts. + expect(state.getSnapshot().draft).toBe('@"/Users/me/my project/" @/Users/me/notes.md ') + const drafts = composer.resolveDraftAttachments?.(state.getSnapshot().attachmentIds) ?? [] + expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) + await vi.waitFor(() => { expect(desktop.rootUpload).toHaveBeenCalledOnce() }) + // A directory the shell cannot name is refused even with the bridge present. + const nameless = new File([], 'nameless') + expect(composer.addFiles?.([nameless], new Set([nameless]))) + .toBe('无法获取文件夹路径,请重新拖入') + await desktop.runtime.dispose() + } finally { + vi.unstubAllGlobals() + } + }) + + it('preserves selected text and file order and restores pasted directory chips from the draft', async () => { + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => `/proj/${file.name}` }) + onTestFinished(() => { vi.unstubAllGlobals(); cleanup() }) + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const composer = b.composerApi(ROOT) + const editor = composer.keyboard!.editor + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('读取') + editor.update(() => { + const text = $getRoot().getAllTextNodes()[0] + if (text === undefined || !$isTextNode(text)) throw new Error('expected text selection') + text.select(0, 2) + }, { discrete: true }) + const off = registerComposerKeymap(editor, { + arbitrate: () => 'pass', space: () => false, dismissPopup: () => {}, + canSubmit: () => true, submit: () => {}, pasteText: (text) => { composer.keyboard!.paste(text) }, + intakeFiles: (files, directories) => { expect(composer.addFiles?.(files, directories)).toBeNull() }, + }) + onTestFinished(off) + const folder = new File([], 'my project') + const files = [new File([], 'a.txt'), folder, new File([], 'b.txt')] + const event = new KeyboardEvent('paste', { cancelable: true }) + Object.defineProperty(event, 'clipboardData', { value: { + items: files.map(file => ({ + kind: 'file', getAsFile: () => file, webkitGetAsEntry: () => ({ isDirectory: file === folder }), + })), getData: () => '', + } }) + editor.update(() => { editor.dispatchCommand(PASTE_COMMAND, event) }, { discrete: true }) + expect(state.getSnapshot().draft).toBe('读取 @a.txt @"my project/" @b.txt ') + const view = render(
{projectUserText(state.getSnapshot().draft, [])}
) + expect(view.container.querySelector('[data-ref-chip="folder"]')?.textContent).toBe('my project') + expect(view.container.querySelector('[data-ref-chip="folder"]')?.getAttribute('title')).toBe('@"my project/"') + }) + + it('rejects the whole file batch before changing the draft when a later path is unrepresentable', async () => { + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => `/proj/${file.name}` }) + onTestFinished(() => { vi.unstubAllGlobals() }) + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('keep this text') + for (const name of ['bad"name', 'bad\nname']) { + const files = [new File([], 'good.txt'), new File([], name)] + expect(b.composerApi(ROOT).addFiles?.(files)).toBe('路径含有无法引用的字符,请改名后再试') + expect(state.getSnapshot().draft).toBe('keep this text') + expect(state.getSnapshot().attachmentIds).toEqual([]) + expect(b.rootUpload).not.toHaveBeenCalled() + } + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index a987916ceb..0e9349e51d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -99,7 +99,7 @@ interface BenchOptions { attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ fileUploads?: DraftFileUploads - addFiles?: (files: readonly File[]) => string | null + addFiles?: (files: readonly File[], directories?: ReadonlySet) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -349,11 +349,34 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addFiles).toHaveBeenCalledWith([image]) + expect(addFiles).toHaveBeenCalledWith([image], undefined) // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) }) + it('passes known clipboard directories and keeps files whose entry metadata is unavailable', () => { + const addFiles = vi.fn(() => null) + const { textarea } = bench({ addFiles }) + const folder = new File([], 'folder with spaces') + const emptyFile = new File([], 'empty-file') + const withoutApi = new File([], 'without-api') + const withoutEntry = new File([], 'without-entry') + fireEvent.paste(textarea, { + clipboardData: { + items: [ + { kind: 'string', getAsFile: () => null }, + { kind: 'file', getAsFile: () => folder, webkitGetAsEntry: () => ({ isDirectory: true }) }, + { kind: 'file', getAsFile: () => emptyFile, webkitGetAsEntry: () => ({ isDirectory: false }) }, + { kind: 'file', getAsFile: () => withoutApi }, + { kind: 'file', getAsFile: () => withoutEntry, webkitGetAsEntry: () => null }, + { kind: 'file', getAsFile: () => null }, + ], + getData: () => '', + }, + }) + expect(addFiles).toHaveBeenCalledWith([folder, emptyFile, withoutApi, withoutEntry], new Set([folder])) + }) + it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => { const limits = { maxImageBytes: 1024 * 1024, @@ -391,7 +414,7 @@ describe('image draft rail', () => { const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) const fits = png(16, 'fits.png') intake(within, [fits]) - expect(within.props.addFiles).toHaveBeenCalledWith([fits]) + expect(within.props.addFiles).toHaveBeenCalledWith([fits], undefined) expect(within.view.queryByRole('alert')).toBeNull() }) @@ -414,7 +437,7 @@ describe('image draft rail', () => { new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), ] act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) - expect(addFiles).toHaveBeenCalledWith(files) + expect(addFiles).toHaveBeenCalledWith(files, undefined) expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') }) @@ -433,6 +456,17 @@ describe('image draft rail', () => { expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' }) }) + it('forwards dropped directories to addFiles and announces its refusal', () => { + const addFiles = vi.fn((_files: readonly File[], directories?: ReadonlySet) => + directories !== undefined && directories.size > 0 ? '只有桌面端支持添加文件夹,浏览器里请添加单个文件' : null) + const result = bench({ addFiles }) + const folder = new File([], 'project') + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + act(() => { attachmentOwner(result.slotCalls).onAddFiles([folder, note], new Set([folder])) }) + expect(addFiles).toHaveBeenCalledWith([folder, note], new Set([folder])) + expect(result.view.getByRole('alert').textContent).toContain('只有桌面端支持添加文件夹') + }) + it('announces server attachment rejections as product copy, other codes as developer text', () => { const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 79bd787254..1dcbd65f9c 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -283,6 +283,11 @@ describe('matrix row: claimed with attachments', () => { act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') + const draft = shell.snapshot.draft + expect(shell.addFiles([{ + source: 'reference', ref: '@note.txt', label: 'note.txt', clipboardText: '@note.txt', + }], ['new-file' as DraftAttachmentId])).toBe(false) + expect(shell.snapshot.draft).toBe(draft) let removed = true act(() => { removed = shell.removeAttachment(img) }) expect(removed).toBe(false) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 1dbe8b1ce5..a4df996a41 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -92,6 +92,9 @@ { "path": "../../util/values" }, + { + "path": "../../context/file-reference" + }, { "path": "../../util/workspace-path" } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 1b599fef00..8ea718d61f 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 236888d89c6fa74e9fabe7e48b271eac5b000f26 -README.zh.md: 2f6a9ebc2c13cf991c74e9cfba5731796214b9f9 +README.md: e9a547d0c3ee46e314ce5166d74d5f26f43df52f +README.zh.md: 122591575030b87fbd55ea660ea7e31aea444ab8 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 236888d89c..e9a547d0c3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -38,6 +38,7 @@ Check this table before writing a control in a feature package. A plugin cannot |---|---| | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. | | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. | +| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. | | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | @@ -62,9 +63,10 @@ Check this table before writing a control in a feature package. A plugin cannot | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | | `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. | -Three pairs are easy to confuse: +Four pairs are easy to confuse: - **`Tag` against `Pill`.** Reach for `Tag` for a read-only badge at the 11px capsule size, and for `Pill` when the capsule is selectable (`active` and `onClick`, as view switchers and filters use) or when it must sit on a 24px text line — `TerminalBlock` renders its exit status as a static `Pill` for exactly that reason. Size decides as much as interactivity here; the two are not interchangeable. +- **`Pill` against `SegmentedControl`.** A row of `Pill`s is a set of independent chips — each one toggles on its own, and several may be active. `SegmentedControl` is one choice among a few mutually exclusive modes, drawn as a tablist with one indicator, and it comes with the tab keyboard pattern (arrow keys walk the segments, only the selected one is in the tab sequence); the Models settings add card switches its two forms with it. - **`DisclosureRow` against a card.** The row lays its title and content side by side at a fixed 24px. A card that stacks a name over a description is a different layout, and belongs in the feature package — `ui-settings-plugins`' `PluginCard` is the precedent and records why. - **`FoldToggle` against the exported surface.** It is package-internal and not exported; the output cards use it for their head-tail fold. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2f6a9ebc2c..1225915750 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -38,6 +38,7 @@ kind: "package-library" |---|---| | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 | | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 | +| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 | | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | @@ -62,9 +63,10 @@ kind: "package-library" | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | | `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 | -有三组容易混淆: +有四组容易混淆: - **`Tag` 与 `Pill`。** 11px 胶囊尺寸的只读徽章用 `Tag`;胶囊可选中(`active` 与 `onClick`,视图切换与筛选器就是这样用的),或者必须落在 24px 文本行上时用 `Pill`——`TerminalBlock` 把退出状态渲染成静态 `Pill` 正是后一种情况。这里尺寸和是否可交互同样是判据,两者不可互换。 +- **`Pill` 与 `SegmentedControl`。** 一排 `Pill` 是一组彼此独立的 chip——每个各自开关,可以同时激活多个。`SegmentedControl` 是在几种互斥模式中选一,画成带一个指示块的 tablist,并自带 tab 键盘模式(方向键在分段间移动,只有选中项在 Tab 序列里);模型设置页的新增卡片就用它切换两张表单。 - **`DisclosureRow` 与卡片。** 该行以固定 24px 把标题与内容左右排列。把名称叠在描述之上的卡片是另一种布局,属于功能包——`ui-settings-plugins` 的 `PluginCard` 是先例,并记录了原因。 - **`FoldToggle` 与对外导出面。** 它是包内组件,未导出;输出卡片用它做头尾折叠。 diff --git a/packages/client/ui-primitives/src/SegmentedControl.module.css b/packages/client/ui-primitives/src/SegmentedControl.module.css new file mode 100644 index 0000000000..a35b25edd4 --- /dev/null +++ b/packages/client/ui-primitives/src/SegmentedControl.module.css @@ -0,0 +1,76 @@ +/* The track is the same translucent fill the hover state uses, so it reads as + * a place rather than a second button; the one raised part is the indicator, + * a white pill that slides under the selected segment. Its stroke rides inside + * the soft elevation shadow (border: 0), the way every elevated surface draws + * its edge, so the elevation gate keeps it off the alias-border set. + * + * Segments are equal-width grid tracks sized to the widest label, which is + * what lets one indicator be placed arithmetically: the tablist carries the + * segment count and the selected index as custom properties, and the indicator + * derives its width and offset from them instead of measuring the DOM. */ +.control { + position: relative; + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: var(--dsw-alias-interactive-bg-hover); +} + +.indicator { + position: absolute; + top: 3px; + left: 3px; + width: calc((100% - 6px - 2px * (var(--dsh-segment-count) - 1)) / var(--dsh-segment-count)); + height: calc(100% - 6px); + border: 0; + border-radius: 7px; + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-elevation-soft); + transform: translateX(calc(var(--dsh-segment-index) * (100% + 2px))); + transition: transform 160ms ease; + pointer-events: none; +} + +.tab { + box-sizing: border-box; + position: relative; + z-index: 1; + height: 28px; + padding: 0 16px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 13px; + line-height: 20px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: color 120ms ease; +} + +.tab:hover:not(:disabled), +.tab[aria-selected='true'] { + color: var(--dsw-alias-label-primary); +} + +.tab:disabled { + cursor: default; + opacity: 0.4; +} + +.tab:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + +@media (prefers-reduced-motion: reduce) { + .indicator, + .tab { + transition: none; + } +} diff --git a/packages/client/ui-primitives/src/SegmentedControl.tsx b/packages/client/ui-primitives/src/SegmentedControl.tsx new file mode 100644 index 0000000000..ffbe2a819e --- /dev/null +++ b/packages/client/ui-primitives/src/SegmentedControl.tsx @@ -0,0 +1,140 @@ +// SegmentedControl: a tablist of two or more equal-width segments with one +// sliding indicator, for switching a card or panel between a few modes. The +// owner holds the selection; `label` is required so the tablist never ships +// without an accessible name, and every segment label is owner-localized. +// Each tab is `-` and controls the panel `--panel`, the +// element the owner renders for that mode and points back at the tab with +// `aria-labelledby`. + +import { useEffect, useRef } from 'react' +import type { CSSProperties, KeyboardEvent } from 'react' +import clsx from 'clsx' +import css from './SegmentedControl.module.css' + +/** One segment of a {@link SegmentedControl}. */ +export interface SegmentedControlOption { + /** The value the owner receives when this segment is chosen. */ + value: Value + /** Localized segment text. */ + label: string + /** Whether the segment refuses selection. */ + disabled?: boolean + /** Localized hover text, typically why the segment is locked. */ + title?: string +} + +/** The keys that walk the tablist, per the WAI-ARIA tabs pattern. */ +type WalkKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'Home' | 'End' + +function isWalkKey(key: string): key is WalkKey { + return key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown' + || key === 'Home' || key === 'End' +} + +/** + * The enabled option a walk key lands on from the selected one: arrows step + * to the nearest enabled neighbour and wrap, Home and End jump to the first + * and last enabled option. + */ +function walk( + options: readonly SegmentedControlOption[], + from: number, + key: WalkKey, +): SegmentedControlOption | undefined { + const enabled = options.filter(option => option.disabled !== true) + if (key === 'Home') return enabled[0] + if (key === 'End') return enabled[enabled.length - 1] + const step = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1 + const count = options.length + for (let offset = 1; offset < count; offset += 1) { + const candidate = options[((from + step * offset) % count + count) % count] + if (candidate !== undefined && candidate.disabled !== true) return candidate + } + return undefined +} + +/** + * Render a segmented control. + * @param props.id - the owner's base id: each tab is `-` and names + * `--panel` as the panel it controls. + * @param props.value - the selected option's value; the control is fully controlled. + * @param props.options - the segments in display order; at least two. + * @param props.onChange - called with the value a click or a walk key asks for, + * never with the value already selected. + * @param props.label - localized accessible name of the tablist. + * @param props.disabled - lock every segment, typically while the shown panel + * has a write or a fetch in flight that switching would orphan. + * @param props.className - extra class for layout placement. + * @returns the tablist element. + */ +export function SegmentedControl({ + id, value, options, onChange, label, disabled = false, className, +}: { + id: string + value: Value + options: readonly SegmentedControlOption[] + onChange: (next: Value) => void + label: string + disabled?: boolean + // `| undefined` so a caller can forward an optional class straight through + // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined). + className?: string | undefined +}) { + const list = useRef(null) + const selected = options.findIndex(option => option.value === value) + + // A walk key reports the next value and the owner re-renders with it; the + // keyboard then follows the selection so the roving tabindex stays usable. + useEffect(() => { + const root = list.current + /* v8 ignore next -- the ref is attached to the always-rendered root before any effect runs. */ + if (root === null) return + if (!root.contains(document.activeElement)) return + root.querySelector('[role="tab"][aria-selected="true"]')?.focus() + }, [value]) + + const onKeyDown = (event: KeyboardEvent): void => { + if (!isWalkKey(event.key)) return + event.preventDefault() + const target = walk(options, selected, event.key) + if (target !== undefined && target.value !== value) onChange(target.value) + } + + const indicator = { + '--dsh-segment-count': String(options.length), + '--dsh-segment-index': String(selected), + } as CSSProperties + + return ( +
+
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 551ced88ea..e40867e127 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -16,6 +16,8 @@ export type { SegmentedTab } from './SegmentedTabs.tsx' export { Tag } from './Tag.tsx' export type { TagTone } from './Tag.tsx' export { Switch } from './Switch.tsx' +export { SegmentedControl } from './SegmentedControl.tsx' +export type { SegmentedControlOption } from './SegmentedControl.tsx' export { Checkbox } from './Checkbox.tsx' export { Input } from './Input.tsx' export { Menu, MenuItemButton } from './Menu.tsx' diff --git a/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx b/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx new file mode 100644 index 0000000000..b9883fbf6a --- /dev/null +++ b/packages/client/ui-primitives/tests/segmented-control.client.spec.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +/** The segmented control is a tablist whose selection the owner holds. */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +type Mode = 'catalog' | 'custom' | 'import' + +const OPTIONS: ReadonlyArray<{ value: Mode; label: string; disabled?: boolean; title?: string }> = [ + { value: 'catalog', label: 'Catalog' }, + { value: 'custom', label: 'Custom' }, + { value: 'import', label: 'Import' }, +] + +function mount(value: Mode, extra: { + options?: typeof OPTIONS + className?: string + disabled?: boolean +} = {}) { + const onChange = vi.fn<(next: Mode) => void>() + const view = render( + , + ) + return { view, onChange } +} + +function tab(name: string): HTMLButtonElement { + return screen.getByRole('tab', { name }) +} + +describe('SegmentedControl', () => { + it('exposes a named tablist with one tab per option and the owner-held selection', () => { + mount('custom') + const list = screen.getByRole('tablist', { name: 'Add mode' }) + expect(list.querySelectorAll('[role="tab"]')).toHaveLength(3) + expect(tab('Catalog').getAttribute('aria-selected')).toBe('false') + expect(tab('Custom').getAttribute('aria-selected')).toBe('true') + expect(tab('Import').getAttribute('aria-selected')).toBe('false') + }) + + it('reports the clicked option and stays silent on the one already selected', () => { + const { onChange } = mount('catalog') + fireEvent.click(tab('Custom')) + expect(onChange).toHaveBeenCalledWith('custom') + onChange.mockClear() + fireEvent.click(tab('Catalog')) + expect(onChange).not.toHaveBeenCalled() + }) + + it('refuses a disabled option and carries its lock reason as the hover title', () => { + const { onChange } = mount('catalog', { + options: [OPTIONS[0]!, { value: 'custom', label: 'Custom', disabled: true, title: 'No protocol is mounted' }], + }) + const locked = tab('Custom') + expect(locked.disabled).toBe(true) + expect(locked.getAttribute('title')).toBe('No protocol is mounted') + fireEvent.click(locked) + expect(onChange).not.toHaveBeenCalled() + }) + + it('keeps only the selected tab in the tab sequence', () => { + mount('custom') + expect(tab('Catalog').tabIndex).toBe(-1) + expect(tab('Custom').tabIndex).toBe(0) + expect(tab('Import').tabIndex).toBe(-1) + }) + + it('moves the selection with the arrow keys, wrapping at either end', () => { + const { onChange } = mount('import') + fireEvent.keyDown(tab('Import'), { key: 'ArrowRight' }) + expect(onChange).toHaveBeenLastCalledWith('catalog') + fireEvent.keyDown(tab('Import'), { key: 'ArrowLeft' }) + expect(onChange).toHaveBeenLastCalledWith('custom') + }) + + it('jumps to the first and last enabled options with Home and End, skipping disabled ones', () => { + const { onChange } = mount('custom', { + options: [ + { value: 'catalog', label: 'Catalog', disabled: true }, + OPTIONS[1]!, + { value: 'import', label: 'Import', disabled: true }, + ], + }) + fireEvent.keyDown(tab('Custom'), { key: 'Home' }) + fireEvent.keyDown(tab('Custom'), { key: 'End' }) + fireEvent.keyDown(tab('Custom'), { key: 'ArrowRight' }) + // Every enabled neighbour is the selected one itself, so nothing changes. + expect(onChange).not.toHaveBeenCalled() + }) + + it('lands Home and End on the first and last enabled options, not the first and last options', () => { + const { onChange } = mount('import', { + options: [{ value: 'catalog', label: 'Catalog', disabled: true }, OPTIONS[1]!, OPTIONS[2]!], + }) + fireEvent.keyDown(tab('Import'), { key: 'Home' }) + expect(onChange).toHaveBeenLastCalledWith('custom') + onChange.mockClear() + fireEvent.keyDown(tab('Import'), { key: 'End' }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('skips disabled options while walking with the arrow keys', () => { + const { onChange } = mount('catalog', { + options: [OPTIONS[0]!, { value: 'custom', label: 'Custom', disabled: true }, OPTIONS[2]!], + }) + fireEvent.keyDown(tab('Catalog'), { key: 'ArrowRight' }) + expect(onChange).toHaveBeenLastCalledWith('import') + fireEvent.keyDown(tab('Catalog'), { key: 'End' }) + expect(onChange).toHaveBeenLastCalledWith('import') + }) + + it('moves keyboard focus onto the newly selected tab once the owner adopts it', () => { + const { view, onChange } = mount('catalog') + tab('Catalog').focus() + fireEvent.keyDown(tab('Catalog'), { key: 'ArrowRight' }) + view.rerender( + , + ) + expect(document.activeElement).toBe(tab('Custom')) + }) + + it('leaves unrelated keys alone', () => { + const { onChange } = mount('catalog') + fireEvent.keyDown(tab('Catalog'), { key: 'Enter' }) + fireEvent.keyDown(tab('Catalog'), { key: 'a' }) + expect(onChange).not.toHaveBeenCalled() + }) + + it('places the sliding indicator by selected index and option count', () => { + mount('import') + const list = screen.getByRole('tablist') + expect(list.style.getPropertyValue('--dsh-segment-count')).toBe('3') + expect(list.style.getPropertyValue('--dsh-segment-index')).toBe('2') + }) + + it('keeps a caller class alongside its own so a render site can place it', () => { + mount('catalog', { className: 'placed' }) + const list = screen.getByRole('tablist') + expect(list.classList.contains('placed')).toBe(true) + expect(list.classList.length).toBeGreaterThan(1) + }) + + it('names each tab and the panel it controls from the owner id, so panels can point back', () => { + mount('catalog') + const catalog = tab('Catalog') + expect(catalog.id).toBe('add-catalog') + expect(catalog.getAttribute('aria-controls')).toBe('add-catalog-panel') + expect(tab('Import').getAttribute('aria-controls')).toBe('add-import-panel') + }) + + it('locks every segment while the owner reports the control disabled', () => { + // A disabled button is unfocusable, so the walk keys cannot originate on + // one; the click path is what a pointer can still reach. + const { onChange } = mount('catalog', { disabled: true }) + for (const name of ['Catalog', 'Custom', 'Import']) expect(tab(name).disabled).toBe(true) + fireEvent.click(tab('Custom')) + expect(onChange).not.toHaveBeenCalled() + }) + + it('never submits a surrounding form', () => { + const onSubmit = vi.fn((event: { preventDefault: () => void }) => { event.preventDefault() }) + render( +
+ {}} /> + , + ) + fireEvent.click(tab('Custom')) + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index a0ed3e4fbe..45e2289a24 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: a47f9a6dfa1faa9257c9b726140838e4858d33bb -README.zh.md: 925f8e976559ef7e975fcd164406f8c42f0b0098 +README.md: fa9be2ef8d7b08e064c3ee5e7517e59013b7f5e1 +README.zh.md: 2974524fba91642aebd31cd155a6495989cb3eaf diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index a47f9a6dfa..fa9be2ef8d 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -43,7 +43,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid ### Adding and deleting providers -The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs @@ -110,7 +110,8 @@ These limits define the editor's field coverage and the page's reach; they are c - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. -- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. +- **Only pi-ai routes can be hand-declared** — the custom-API form writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. +- **The catalog select lists route identifiers** — `moonshotai`, `zai`, and the other pi-ai catalog ids appear as they are, with no product names, aliases, or search. A searchable picker with the custom form as one pinned entry would let the mode switch go; it needs the directory to carry display names first. - **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 925f8e9765..2974524fba 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端的模型设置与产品引导插件:提供方行、API 密钥管理、模型列表与 DeepSeek 首次运行弹窗。" +description: "dsh Web 客户端的模型设置与产品引导插件:提供商行、API 密钥管理、模型列表与 DeepSeek 首次运行弹窗。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供方的模型列表,并手工声明自定义 pi-ai 路由;页面以提供方行展示,一次只展开一张编辑卡片。该页面把提供方目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 +`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。 ## 目录 @@ -25,33 +25,33 @@ kind: "package-reference" ## 使用本包 -从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 +从设置导航打开 Models 页面,即可看到每个已配置的提供商都有一行。其配置键未在任何位置配置的整分节提供商会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 -存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 +存在已存储目录错误的提供商仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 ### API 密钥 -编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 +编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 -### 编辑提供方 +### 编辑提供商 -收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 +收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 -展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 +展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 -### 新增与删除提供方 +### 新增与删除提供商 -「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 -版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供方都会直接结束该步骤、不做渲染;只有没有任何提供方的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。 +版本化声明步骤完成后,DeepSeek 步骤从同一份合并快照投影首次运行就绪状态。用户已经能够到达的**任何**提供商都会直接结束该步骤、不做渲染;只有没有任何提供商的用户才会被询问官方 DeepSeek 密钥。「稍后配置」只完成这次协调器遍历;适配器缺失、路由不活动、合并失败、只读部署或能力不可用时,该步骤不渲染即完成——Models 仍是诊断界面。 ### 扩展 slot -本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 +本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 ----- @@ -61,7 +61,7 @@ kind: "package-reference"
实现细节——点击展开 -页面只持有脱敏后的描述符,从不持有完整设置分区:因此每次编辑都以 `settings.mutate` 路径操作落到已存分区上——每个改动字段一次 set、每个清除字段一次 unset、删除提供方行则一次 unset。 +页面只持有脱敏后的描述符,从不持有完整设置分区:因此每次编辑都以 `settings.mutate` 路径操作落到已存分区上——每个改动字段一次 set、每个清除字段一次 unset、删除提供商行则一次 unset。 ### 校验 @@ -73,7 +73,7 @@ kind: "package-reference" ### 引导协调器 -声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。 +声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供商设置。
@@ -87,7 +87,7 @@ kind: "package-reference" - [ui-settings](../ui-settings/README.zh.md)——本页所依赖 scope 与 schema 服务所在的领域底座。 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 - [credentials](../../credentials/README.zh.md)——本页写入密钥所经的凭据引用 seam。 -- [llm](../../llm/README.zh.md)——本页所配置提供方所在的适配器注册表。 +- [llm](../../llm/README.zh.md)——本页所配置提供商所在的适配器注册表。 - [Web 配置平面](../../../.agents/notes/archived/architecture/2026-07-30-web-config-plane.md)——手写编辑器的设计依据。 ----- @@ -99,7 +99,7 @@ kind: "package-reference" #### KV Cache 影响 -无;该包既不组装也不发送提供方请求。 +无;该包既不组装也不发送提供商请求。 ## 已知限制与延期工作 @@ -110,7 +110,8 @@ kind: "package-reference" - **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 -- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 +- **只有 pi-ai 路由可以手工声明**:自定义模型 API 表单写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供商的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 +- **目录选择框列出的是路由标识符**:`moonshotai`、`zai` 等 pi-ai catalog id 原样显示,没有产品名、别名或搜索。一个把自定义表单作为置顶项的可搜索选择器可以取代方式切换;前提是目录先携带显示名称。 - **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 4f382e92a1..f568ba0103 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -3,12 +3,15 @@ * gateway, a self-hosted server, or a provider newer than the installed * catalog. * - * This is a create, not an edit, which is why it is its own card rather than + * This is a create, not an edit, which is why it is its own form rather than * the provider editor with extra fields: the route id is being *chosen* here, - * and the settings address does not exist until it is. One `settings.mutate` - * sets the whole profile at `providers.`; the key travels separately - * through `credentials/set` under the reference the profile records, exactly as - * an existing provider's key does. + * and the settings address does not exist until it is. It renders as the + * custom-API panel of the section's add card; the card's mode switch names it + * when both modes are offered, and with the custom mode alone the card shows + * this form directly. One `settings.mutate` sets the whole profile at + * `providers.`; the key travels separately through `credentials/set` + * under the reference the profile records, exactly as an existing provider's + * key does. * * The three fields a hand-declared route cannot default — endpoint, protocol, * and at least one model — are required here rather than at load, so the @@ -21,7 +24,7 @@ * levels instead. */ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { apiKeyFailure } from './apiKey.ts' @@ -30,6 +33,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' import { deriveKeyRef } from './store.ts' +import { protocolLabel } from './protocol-label.ts' import type { ModelsOperations } from './operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -76,6 +80,11 @@ export interface CustomProviderCardProps { readOnly: boolean /** Close the card; `changed` reports whether a provider was created. */ onClose: (changed: boolean) => void + /** + * Called once per change with whether the create or the list's endpoint + * interrogation is in flight, so the owner can hold its surface still. + */ + onBusyChange?: (busy: boolean) => void } /** @@ -84,7 +93,7 @@ export interface CustomProviderCardProps { * @returns the creation card. */ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { - const { taken, protocols, operations, t } = props + const { taken, protocols, operations, t, onBusyChange } = props // The write is checked against the revision on which this draft was opened. const [openedAt] = useState(() => props.revision) const [route, setRoute] = useState('') @@ -94,6 +103,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [keyDraft, setKeyDraft] = useState('') const [models, setModels] = useState([]) const [busy, setBusy] = useState(false) + const [listBusy, setListBusy] = useState(false) + useEffect(() => { onBusyChange?.(busy || listBusy) }, [busy, listBusy, onBusyChange]) const [failure, setFailure] = useState(undefined) /** * The profile write landed. Only the key write can still be outstanding, so @@ -198,9 +209,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { return (
-
- {t('customTitle')} -
{t('customRoute')} { setProtocol(event.target.value) }} > - {protocols.map(choice => )} + {protocols.map(choice => )}
@@ -292,6 +300,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { operations={operations} t={t} disabled={profileDisabled} + onBusyChange={setListBusy} /> {failure !== undefined ?

{failure}

: null} {/* Only the gates with something to say render; the route-id gate has its diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 7cd0a7cdcb..e8ce776875 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -90,6 +90,13 @@ export interface ModelListEditorProps { t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ disabled: boolean + /** + * Called once per change with whether an endpoint interrogation is in + * flight. The owning card folds it into its own busy state so the surface + * around the card — a mode switch, say — can refuse to move while the + * answer, and the picker it opens, is still bound for this list. + */ + onBusyChange: (busy: boolean) => void } /** The two token counts edited as K/M-suffixed text behind a row's disclosure. */ @@ -139,9 +146,10 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft { * @returns the model-list editor. */ export function ModelListEditor(props: ModelListEditorProps): ReactNode { - const { models, onChange, probe, operations, t, disabled } = props + const { models, onChange, probe, operations, t, disabled, onBusyChange } = props const { catalogProvider } = props const [busy, setBusy] = useState(false) + useEffect(() => { onBusyChange(busy) }, [busy, onBusyChange]) const [failure, setFailure] = useState(undefined) const [inheritedCatalog, setInheritedCatalog] = useState<{ provider: string diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 15b8933fa3..d39e807876 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -305,20 +305,16 @@ gap: 12px; } -/* The two ways to gain a provider, as equal siblings spanning the same width - as the rows above. Wraps rather than shrinking below a legible label. */ +/* The one entry to the add card, spanning the same width as the rows above. */ .addActions { display: flex; - flex-wrap: wrap; - gap: 10px; } .addButton { - /* Overrides the shared button base above: these two are not pills sitting in - a footer but the last slot of the provider list, so they split the row - evenly and repeat the row cards' corner. Dashed, like every other "nothing - here yet" affordance on this page, to read as a place rather than a - command. */ + /* Overrides the shared button base above: this is not a pill sitting in a + footer but the last slot of the provider list, so it fills the row and + repeats the row cards' corner. Dashed, like every other "nothing here yet" + affordance on this page, to read as a place rather than a command. */ flex: 1 1 0; min-width: 180px; gap: 6px; @@ -327,6 +323,28 @@ border-radius: 16px; } +/* The add card's mode switch with the one-line purpose of the chosen mode + beneath it; the switch hugs its labels rather than spanning the card. */ +.addModes { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +/* One panel per mode. Both stay mounted while the card is open; the hidden + attribute must win over the panel's own display, or the inactive draft + would show beneath the active one. */ +.addPanel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.addPanel[hidden] { + display: none; +} + .addCard, .setupCard { border-radius: 12px; diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index a9ce163a50..7ae8118762 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -5,17 +5,26 @@ * solid configured or missing dots. A whole-section provider without a * configured key renders as its open setup card instead of a row, but only in * the first-run posture — no provider on the page can serve requests yet — and - * only until the user closes that card; the add flow is a card carrying the - * dormant-provider select. Each card kind owns its own open state, so closing - * one never discards a draft in another. Every mutation writes through the - * wire, while a provider removal first requires confirmation; the page - * re-renders from pushed invalidations or the post-apply reload. + * only until the user closes that card. The add flow is one card behind one + * button: a mode switch chooses between adopting a dormant directory provider + * (the catalog select over the provider editor) and declaring a custom model + * API (the create form). A panel mounts the first time its mode is shown and + * stays mounted, hidden, while the card is open and its mode stays offered, + * so switching modes discards neither draft and an unvisited mode costs + * nothing; the switch holds still while either panel has a write or an + * endpoint interrogation in flight, since a switch underneath one would + * orphan the answer. Each card kind owns its own open state, so closing one + * never discards a draft in another. Every + * mutation writes through the wire, while a provider removal first requires + * confirmation; the page re-renders from pushed invalidations or the + * post-apply reload. */ -import { useState } from 'react' +import { useId, useState } from 'react' import type { ReactNode } from 'react' -import { Button, IconPlusOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconPlusOutlineRegular, Modal, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls this package's SlotMap merge (the two Models child slots). import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' @@ -43,6 +52,12 @@ export interface ModelsSectionInjected { t: (key: keyof typeof en) => string } +/** + * The two ways the add card gains a provider: adopt a directory row the + * adapter already knows, or declare a route it does not. + */ +type AddMode = 'catalog' | 'custom' + /** The child slots this section declares and dispatches (see ./slot-contract.ts). */ type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer' @@ -78,6 +93,18 @@ interface EditorTarget extends ProviderIdentity { declared?: boolean } +/** A dormant directory row the add card can adopt, with its registered namespace. */ +interface AddableRow { + row: ProviderRow + namespace: SettingsNamespaceView +} + +/** The catalog draft the add card shows: its editor target and the namespace that takes the write. */ +interface CatalogDraft { + target: EditorTarget + namespace: SettingsNamespaceView +} + /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, @@ -205,12 +232,19 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS const { controller, operations, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) - const [adding, setAdding] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [addMode, setAddMode] = useState('catalog') + /** The modes shown since the add card opened; each keeps its panel mounted. */ + const [visited, setVisited] = useState>(() => new Set()) + /** Whether each add panel has a write or an interrogation in flight. */ + const [catalogBusy, setCatalogBusy] = useState(false) + const [customBusy, setCustomBusy] = useState(false) + /** Base of the add card's tab and panel ids. */ + const addId = useId() const [deleteTarget, setDeleteTarget] = useState(undefined) const [deleting, setDeleting] = useState(false) const [deleteFailure, setDeleteFailure] = useState(undefined) const [savedTarget, setSavedTarget] = useState(undefined) - const [declaring, setDeclaring] = useState(false) const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) const announceSaved = (target: ProviderIdentity): void => { @@ -220,19 +254,31 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS void controller.load().then(() => { setSavedTarget(target) }) } - const closeEditor = (changed: boolean, target: ProviderIdentity): void => { + /** + * Close the add card whole. The catalog target is forgotten with it, since + * `editing` doubles as the row editor's target once the card is closed and a + * refresh could otherwise open the row of a provider the draft never saved. + * The busy flags reset here because a panel that closes itself on success + * unmounts before it can report idle. + */ + const closeAdd = (): void => { setEditing(undefined) - setAdding(false) - setDeclaring(false) + setAddOpen(false) + setCatalogBusy(false) + setCustomBusy(false) + } + + const closeEditor = (changed: boolean, target: ProviderIdentity): void => { + closeAdd() if (changed) announceSaved(target) } /** - * Close a setup card, which owns none of the state above: the row-editor, - * add, and declare cards each own one of those, so clearing them here would - * discard a draft the user opened beside this card. Dismissal is this card's - * own — the provider falls back to an ordinary row for the rest of the - * session, and reopens through Edit. + * Close a setup card, which owns none of the state above: the row-editor + * and add cards each own one of those, so clearing them here would discard + * a draft the user opened beside this card. Dismissal is this card's own — + * the provider falls back to an ordinary row for the rest of the session, + * and reopens through Edit. */ const closeSetup = (changed: boolean, target: ProviderIdentity): void => { setDismissedSetup(previous => new Set([...previous, target.provider])) @@ -291,19 +337,47 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs)) - const addable = configurable.filter(row => !row.configured) - const addTarget = adding ? editing : undefined - const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs) + const addable: AddableRow[] = state.rows.flatMap((row) => { + const namespace = state.namespaces.get(row.entry.settingsNs) + return namespace === undefined || row.configured ? [] : [{ row, namespace }] + }) + // Hand-declared routes live in the pi-ai namespace, which is also the only + // one whose schema names the protocols one may speak; without it mounted + // there is nothing to declare and the mode is not offered. + const piAi = state.namespaces.get('llm-pi-ai') + const protocols = protocolChoices(piAi, schema) + // Each mode is offered while its namespace is mounted and enabled while it + // has something to offer; the card shows the chosen mode where both are + // offered, else the only one there is. A mode's panel is mounted while it is + // the shown mode or has been shown since the card opened — derived, so a + // refresh that changes which modes are offered can never leave the card + // without a panel. + const catalogOffered = configurable.length > 0 + const catalogEnabled = addable.length > 0 + const customOffered = piAi !== undefined + const customEnabled = protocols.length > 0 + const bothOffered = catalogOffered && customOffered + const mode: AddMode = bothOffered ? addMode : customOffered ? 'custom' : 'catalog' + const mounted = (candidate: AddMode): boolean => mode === candidate || visited.has(candidate) + const switchLocked = catalogBusy || customBusy + // The catalog draft: the row the user chose, kept through a refresh that + // adopts or withdraws it elsewhere so a typed key is never discarded, for as + // long as its namespace can still take the write; else the first row still + // addable, since the mode can be entered by a refresh as well as by the + // switch and the button only picks a target when it opens the card. + const draft = ((): CatalogDraft | undefined => { + if (!addOpen || !catalogOffered) return undefined + const kept = editing === undefined ? undefined : state.namespaces.get(editing.settingsNs) + if (editing !== undefined && kept !== undefined) return { target: editing, namespace: kept } + const first = addable[0] + return first === undefined ? undefined : { target: targetOf(first.row), namespace: first.namespace } + })() // The draft's directory row, for the card extension seat. A refresh can drop // the row mid-draft (the route was adopted or withdrawn elsewhere); the // draft card stays while the seat simply has no row to dispatch. - const addRow = addTarget === undefined + const addRow = draft === undefined ? undefined - : state.rows.find(row => row.entry.provider === addTarget.provider) - // Hand-declared routes live in the pi-ai namespace, which is also the only - // one whose schema names the protocols one may speak; without it mounted - // there is nothing to declare and the entry point stays disabled. - const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) + : state.rows.find(row => row.entry.provider === draft.target.provider) return (
@@ -349,7 +423,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS ) } - const open = !adding && editing?.provider === row.entry.provider + const open = !addOpen && editing?.provider === row.entry.provider const credentialConfigured = row.credential?.configured === true const credentialMissing = !credentialConfigured && row.apiKeyEnv !== undefined @@ -392,11 +466,10 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS aria-label={providerCopy(t('editProvider'), target)} onClick={() => { setSavedTarget(undefined) - // One card at a time: leaving `declaring` set would show - // the create card beside this editor, and closing either - // one discards the other's draft. - setDeclaring(false) - setAdding(false) + // One card at a time: the add card closes with whatever + // it held, since closing either card would otherwise + // discard the other's draft. + setAddOpen(false) setEditing(open ? undefined : target) }} > @@ -443,110 +516,150 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS })}
- {addTarget !== undefined && addNamespace !== undefined + {addOpen ? (
-
- {t('provider')} - +
+ {bothOffered + ? ( + { + setAddMode(next) + setVisited(previous => new Set([...previous, next])) + }} + /> + ) + : ( + // One mode alone has no switch to name it, so the card + // carries the mode as its title instead. +
+ {t(mode === 'catalog' ? 'addCatalog' : 'addCustom')} +
+ )} +

+ {t(mode === 'catalog' ? 'addCatalogHint' : 'addCustomHint')} +

- { closeEditor(changed, addTarget) }} - /> - {addRow === undefined - ? null - : renderSlot( - 'settings.models.provider-card', - { provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) }, - { entryKey: addRow.entry.settingsNs }, - )} + {mounted('catalog') && draft !== undefined + ? ( + + ) + : null} + {mounted('custom') && piAi !== undefined + ? ( + + ) + : null}
) - : declaring + : catalogOffered || customOffered ? ( -
- row.entry.provider)} - protocols={protocols} - /* v8 ignore next -- the card only opens from a button disabled without this namespace */ - revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0} - operations={operations} - t={t} - readOnly={!state.writable} - onClose={(changed) => { - setDeclaring(false) - if (changed) void controller.load() + // One entry for both ways to gain a provider; the card behind it + // splits them. Full width, so it lines up with the rows above. +
+
) - : ( - // One row for the two ways to gain a provider: adopt one the - // adapter already knows, or declare one it does not. Side by side - // and equal-width so they read as siblings and line up with the - // rows above, rather than two pills of different lengths. -
- {configurable.length > 0 && ( - - )} - {state.namespaces.has('llm-pi-ai') && ( - - )} -
- )} + : null}
{renderSlot('settings.models.footer', {})} void + /** + * Called once per change with whether the apply or the model list's + * endpoint interrogation is in flight, so the owner can hold its surface + * still. + */ + onBusyChange?: (busy: boolean) => void } /** A user-section subtree as a plain draft object (absent → empty). */ @@ -160,6 +167,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) + const [listBusy, setListBusy] = useState(false) + const { onBusyChange } = props + useEffect(() => { onBusyChange?.(busy || listBusy) }, [busy, listBusy, onBusyChange]) const [failure, setFailure] = useState(undefined) // A settings success advances both retry baselines immediately. Keeping the // derived fields in the draft prevents a pushed namespace refresh from @@ -445,7 +455,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { reader announces it either way, and an empty one is announced as a choice with no identity. */} {probeApi === undefined ? : null} - {protocols.map(choice => )} + {protocols.map(choice => )}
) @@ -471,6 +481,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { probe={probe} probeBlocked={keyFailure} operations={operations} + onBusyChange={setListBusy} /> )}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 81f1c011c9..c60caf4976 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -14,7 +14,14 @@ export const en = { deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', deleteConfirm: 'Delete {provider}', deleting: 'Deleting {provider}…', - add: 'Add provider', + add: 'Add model provider', + addMode: 'How to add', + addCatalog: 'Third-party model provider', + addCustom: 'Custom model API', + addCatalogHint: 'Pick OpenAI, Anthropic, Kimi, or another provider from the built-in catalog and enter its API key.', + addCustomHint: 'Connect a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint by its base URL, protocol, and models.', + addCatalogExhausted: 'Every catalog provider is already configured.', + addCustomUnavailable: 'No API protocol is available to declare.', provider: 'Provider', close: 'Close', cancel: 'Cancel', @@ -78,8 +85,6 @@ export const en = { fetchSelectAll: 'Select all', fetchDeselectAll: 'Deselect all', fetchAdopt: 'Add selected', - customAdd: 'Add a custom provider', - customTitle: 'Custom provider', customTag: 'Custom', customRoute: 'Provider ID', customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.', @@ -88,6 +93,9 @@ export const en = { customDisplayName: 'Display name', customApi: 'API protocol', customApiUnset: 'Not selected', + protocolOpenAiCompletions: 'OpenAI Chat Completions', + protocolOpenAiResponses: 'OpenAI Responses', + protocolAnthropicMessages: 'Anthropic Messages', customNeedsBaseUrl: 'A custom provider needs a base URL.', customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.', customNeedsModels: 'A custom provider needs at least one model.', @@ -115,7 +123,7 @@ export type ModelsKey = keyof typeof en export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', - intro: '填入各提供方的 API 密钥即可使用其模型。', + intro: '填入各提供商的 API 密钥即可使用其模型。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', @@ -125,8 +133,15 @@ export const zh: { [Key in keyof typeof en]: string } = { deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', deleteConfirm: '删除 {provider}', deleting: '正在删除 {provider}…', - add: '添加提供方', - provider: '提供方', + add: '添加模型提供商', + addMode: '添加方式', + addCatalog: '第三方模型提供商', + addCustom: '自定义模型 API', + addCatalogHint: '从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。', + addCustomHint: '连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。', + addCatalogExhausted: '目录中的提供商都已添加。', + addCustomUnavailable: '没有可用的 API 协议。', + provider: '提供商', close: '关闭', cancel: '取消', apply: '保存', @@ -135,7 +150,7 @@ export const zh: { [Key in keyof typeof en]: string } = { credentialConfigured: 'API 密钥已配置', credentialMissing: 'API 密钥缺失', readOnly: '当前部署的设置文档为只读。', - loadFailed: '加载提供方目录失败', + loadFailed: '加载提供商目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', retry: '重试', keyInput: 'API 密钥', @@ -145,7 +160,7 @@ export const zh: { [Key in keyof typeof en]: string } = { keyEnvLocked: '由启动环境提供(只读)', customized: '自定义设置', baseUrl: 'API 地址', - baseUrlDefault: '提供方默认', + baseUrlDefault: '提供商默认', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', models: '模型目录', @@ -157,9 +172,9 @@ export const zh: { [Key in keyof typeof en]: string } = { modelName: '显示名称', modelNamePlaceholder: '留空时使用模型 ID', contextWindow: '上下文窗口', - contextWindowPlaceholder: '使用提供方默认值', + contextWindowPlaceholder: '使用提供商默认值', maxTokens: '最大输出 token 数', - maxTokensPlaceholder: '使用提供方默认值', + maxTokensPlaceholder: '使用提供商默认值', modelAdvanced: '模型选项', modelInputTypes: '输入类型', modelInputText: '文本', @@ -168,7 +183,7 @@ export const zh: { [Key in keyof typeof en]: string } = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', - keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', + keyBlankNew: '请输入 API 密钥;若该提供商以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', @@ -179,33 +194,34 @@ export const zh: { [Key in keyof typeof en]: string } = { modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。', modelDuplicate: '每个模型 ID 只能出现一次。', fetchModels: '获取可用模型', - fetching: '正在询问提供方\u2026', + fetching: '正在询问提供商\u2026', fetchNeedsBaseUrl: '请先填写 API 地址,再获取。', - fetchEmpty: '该提供方没有列出任何模型,请手动添加。', + fetchEmpty: '该提供商没有列出任何模型,请手动添加。', fetchTitle: '选择要添加的模型', - fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。', + fetchDescription: '以下是模型提供商的可用模型,勾选要添加的模型。', fetchSearch: '搜索模型', fetchNoMatches: '没有匹配的模型。', fetchSelectAll: '全选', fetchDeselectAll: '取消全选', fetchAdopt: '添加所选', - customAdd: '添加自定义提供方', - customTitle: '自定义提供方', customTag: '自定义', customRoute: 'Provider ID', - customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。', + customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供商,并用于派生凭据名。', customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。', - customRouteTaken: '已有提供方使用了这个 ID。', + customRouteTaken: '已有提供商使用了这个 ID。', customDisplayName: '显示名称', customApi: 'API 协议', customApiUnset: '未选择', - customNeedsBaseUrl: '自定义提供方需要填写 API 地址。', + protocolOpenAiCompletions: 'OpenAI Chat Completions', + protocolOpenAiResponses: 'OpenAI Responses', + protocolAnthropicMessages: 'Anthropic Messages', + customNeedsBaseUrl: '自定义模型 API 需要填写 API 地址。', customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。', - customNeedsModels: '自定义提供方至少需要一个模型。', + customNeedsModels: '自定义模型 API 至少需要一个模型。', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', settingsPathUnresolvable: '无法解析设置路径', - create: '创建提供方', + create: '创建提供商', creating: '创建中\u2026', welcomeTitle: '内测声明', welcomeBody: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。', diff --git a/packages/client/ui-settings-models/src/client/protocol-label.ts b/packages/client/ui-settings-models/src/client/protocol-label.ts new file mode 100644 index 0000000000..f65323f32c --- /dev/null +++ b/packages/client/ui-settings-models/src/client/protocol-label.ts @@ -0,0 +1,27 @@ +/** + * Product names for the wire protocols a pi-ai route may speak. The pickers + * show these instead of the schema identifiers (`openai-completions`), which + * are what `settings.yaml` carries and what the option values stay. + */ + +import type { ModelsKey } from './locales.ts' + +/** The protocols this page names, keyed by their schema identifier. */ +const PROTOCOL_LABEL_KEYS: Readonly> = { + 'openai-completions': 'protocolOpenAiCompletions', + 'openai-responses': 'protocolOpenAiResponses', + 'anthropic-messages': 'protocolAnthropicMessages', +} + +/** + * The picker text for one protocol identifier. + * @param t - section copy. + * @param protocol - the schema identifier of the protocol. + * @returns the product name this page knows the protocol by; a protocol the + * adapter adds before this page names it shows its identifier, the spelling + * `settings.yaml` needs anyway. + */ +export function protocolLabel(t: (key: ModelsKey) => string, protocol: string): string { + const key = PROTOCOL_LABEL_KEYS[protocol] + return key === undefined ? protocol : t(key) +} diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 14236346c1..f11b47f15f 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -46,6 +46,7 @@ function capacityInputs(label: string): HTMLInputElement[] { const PiAiConfig = Schema.object({ providers: Schema.dict(Schema.object({ apiKeyEnv: Schema.string().role('credential-ref'), + api: Schema.union(['openai-completions', 'openai-responses', 'anthropic-messages']), baseURL: Schema.string(), reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), headers: Schema.dict(Schema.string()), @@ -307,12 +308,11 @@ async function mountDeepSeekCard(overrides: Parameters[0] = } describe('ModelsSection', () => { - it('hides both add actions when their settings namespaces are absent', async () => { + it('hides the add action when no settings namespace can open an editor', async () => { const scripted = scriptedFace() scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] })) await mountFace(scripted) expect(screen.queryByRole('button', { name: en.add })).toBeNull() - expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() }) it('offers only providers whose settings namespace can open an editor', async () => { @@ -322,8 +322,11 @@ describe('ModelsSection', () => { namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'), })) await mountFace(scripted) - expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.add })) + // Without the pi-ai namespace nothing can be hand-declared, so the card + // is the catalog form alone: no mode switch, no custom panel. + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull() expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy() }) @@ -339,7 +342,6 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) expect(await screen.findByLabelText(en.keyInput)).toBeTruthy() expect(screen.getByRole('button', { name: en.add })).toBeTruthy() - expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) it('renders nothing before the slot injects its dependencies', () => { @@ -1437,6 +1439,253 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) + it('opens the add card on the catalog mode with both modes offered', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + const modes = screen.getByRole('tablist', { name: en.addMode }) + expect(within(modes).getByRole('tab', { name: en.addCatalog }).getAttribute('aria-selected')).toBe('true') + expect(within(modes).getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('false') + expect(screen.getByText(en.addCatalogHint)).toBeTruthy() + expect(screen.queryByText(en.addCustomHint)).toBeNull() + expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy() + // The custom panel is mounted but hidden, so its fields are not reachable. + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() + }) + + it('switches between the modes without losing either draft', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-catalog' } }) + + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + expect(screen.getByText(en.addCustomHint)).toBeTruthy() + expect(screen.queryByText(en.addCatalogHint)).toBeNull() + expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull() + fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } }) + + // Both panels stay mounted from here on, so the shown one is queried by name. + fireEvent.click(screen.getByRole('tab', { name: en.addCatalog })) + const catalog = screen.getByRole('tabpanel', { name: en.addCatalog }) + expect(within(catalog).getByLabelText(en.keyInput).value).toBe('sk-catalog') + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + expect(screen.getByRole('textbox', { name: en.customRoute }).value).toBe('acme') + }) + + it('opens on the custom mode when every catalog provider is already configured', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ])) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + const catalog = screen.getByRole('tab', { name: en.addCatalog }) + expect(catalog.disabled).toBe(true) + expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('true') + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + }) + + it('shows the custom form alone when no directory row can be adopted', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([])) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + }) + + it('disables the add action when neither mode can proceed', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ])) + // A pi-ai schema naming no protocol leaves nothing to declare either. + const protocolless = JSON.parse(JSON.stringify( + Schema.object({ providers: Schema.dict(Schema.object({})) }).toJSON(), + )) as JsonValue + scripted.face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' ? { ...view, schema: protocolless } : view), + })) + await mountFace(scripted) + expect(screen.getByRole('button', { name: en.add }).disabled).toBe(true) + }) + + it('shows the custom panel when a refresh drops every configurable row mid-card', async () => { + const { face, controller } = await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy() + // The directory empties while the card is open: the only mode left is the + // custom one, whose panel was never visited. + face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([])) + await act(async () => { await controller.load() }) + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + }) + + it('picks a catalog target when the catalog becomes addable after the card opened', async () => { + const scripted = scriptedFace() + const exhausted = [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ] + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk(exhausted)) + const { controller } = await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.getByRole('tab', { name: en.addCatalog }).disabled).toBe(true) + // A dormant route appears (another client deleted its profile, say). + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + ...exhausted, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'] }, + ])) + await act(async () => { await controller.load() }) + const catalog = screen.getByRole('tab', { name: en.addCatalog }) + expect(catalog.disabled).toBe(false) + fireEvent.click(catalog) + expect(screen.getByRole('combobox', { name: en.provider }).value).toBe('anthropic') + expect(within(screen.getByRole('tabpanel', { name: en.addCatalog })).getByLabelText(en.keyInput)).toBeTruthy() + }) + + it('drops the custom panel when the pi-ai namespace disappears mid-card', async () => { + const { face, controller, mirror } = await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'), + })) + // A namespace change reaches the page through the mirror's own refresh. + await act(async () => { + await mirror.load() + await controller.load() + }) + // Nothing can be declared any more, so the form is gone rather than left + // to create a route the Host would refuse; the catalog form stands alone. + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy() + }) + + it('forgets the catalog target when the custom form closes, so a later refresh opens no row editor', async () => { + const { face, controller, mirror } = await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.getByRole('combobox', { name: en.provider }).value).toBe('anthropic') + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + fireEvent.click(within(screen.getByRole('tabpanel', { name: en.addCustom })).getByText(en.cancel)) + expect(screen.queryByRole('tablist')).toBeNull() + // The draft's provider gets configured elsewhere: its row must arrive closed. + const withAnthropic = (layer: unknown): JsonValue => ({ + providers: { ...(layer as { providers: object }).providers, anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } }, + }) + face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' + ? { ...view, value: withAnthropic(view.value), user: withAnthropic(view.user) } + : view), + })) + await act(async () => { + await mirror.load() + await controller.load() + }) + expect(screen.getByRole('button', { name: providerCopy(en.editProvider, { provider: 'anthropic', displayName: 'anthropic' }) })).toBeTruthy() + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) + }) + + it('locks the mode switch while the catalog form has a write in flight', async () => { + let settle: (() => void) | undefined + const pending = new Promise((resolve) => { settle = resolve }) + const providerNamespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')! + const mutate = vi.fn(() => pending.then(() => remoteOk(providerNamespace))) + await mountSection({ mutate }) + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-slow' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.getByRole('tab', { name: en.addCustom }).disabled).toBe(true) }) + expect(screen.getByRole('tab', { name: en.addCatalog }).disabled).toBe(true) + await act(async () => { settle!(); await pending }) + // The apply closes the card once it lands; nothing was switched underneath it. + await waitFor(() => { expect(screen.queryByRole('tablist')).toBeNull() }) + expect(mutate).toHaveBeenCalledOnce() + }) + + it('locks the mode switch while the custom form is asking the endpoint for models', async () => { + const scripted = scriptedFace() + let settle: (() => void) | undefined + const pending = new Promise((resolve) => { settle = resolve }) + scripted.face.llm.discoverModels.mockImplementation(() => pending.then(() => remoteOk([]))) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + const custom = within(screen.getByRole('tabpanel', { name: en.addCustom })) + fireEvent.change(custom.getByRole('textbox', { name: en.baseUrl }), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(custom.getByRole('button', { name: en.fetchModels })) + await waitFor(() => { expect(screen.getByRole('tab', { name: en.addCatalog }).disabled).toBe(true) }) + await act(async () => { settle!(); await pending }) + await waitFor(() => { expect(screen.getByRole('tab', { name: en.addCatalog }).disabled).toBe(false) }) + }) + + it('pairs each tab with its panel through ids', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + const catalogTab = screen.getByRole('tab', { name: en.addCatalog }) + const catalogPanel = screen.getByRole('tabpanel', { name: en.addCatalog }) + expect(catalogTab.getAttribute('aria-controls')).toBe(catalogPanel.id) + expect(catalogPanel.getAttribute('aria-labelledby')).toBe(catalogTab.id) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + const customTab = screen.getByRole('tab', { name: en.addCustom }) + const customPanel = screen.getByRole('tabpanel', { name: en.addCustom }) + expect(customTab.getAttribute('aria-controls')).toBe(customPanel.id) + expect(customPanel.getAttribute('aria-labelledby')).toBe(customTab.id) + }) + + it('titles a single-mode card with its mode instead of leaving an orphan tabpanel', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([])) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('tabpanel')).toBeNull() + expect(screen.getByText(en.addCustom)).toBeTruthy() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() + }) + + it('explains a locked mode through its hover title', async () => { + const scripted = scriptedFace() + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ])) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.getByRole('tab', { name: en.addCatalog }).getAttribute('title')).toBe(en.addCatalogExhausted) + expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('title')).toBeNull() + }) + + it('explains a mode with no protocol to declare through its hover title', async () => { + const scripted = scriptedFace() + const protocolless = JSON.parse(JSON.stringify( + Schema.object({ providers: Schema.dict(Schema.object({})) }).toJSON(), + )) as JsonValue + scripted.face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' ? { ...view, schema: protocolless } : view), + })) + await mountFace(scripted) + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('title')).toBe(en.addCustomUnavailable) + expect(screen.getByRole('tab', { name: en.addCatalog }).getAttribute('title')).toBeNull() + }) + + it('collapses the add card from the custom mode on cancel', async () => { + await mountSection() + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + fireEvent.click(within(screen.getByRole('tabpanel', { name: en.addCustom })).getByText(en.cancel)) + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.getByRole('button', { name: en.add })).toBeTruthy() + }) + it('cancels the add card back to the add button', async () => { await mountSection() fireEvent.click(screen.getByText(en.add)) diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index 60e7d79165..a0034f2f7c 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -27,7 +27,7 @@ it('ignores a late catalog response after the provider changes', async () => { const onChange = vi.fn() const props = { models: [{ id: 'm' }], onChange, operations: actions, - disabled: false, t: (key: keyof typeof en) => en[key], + disabled: false, t: (key: keyof typeof en) => en[key], onBusyChange: () => {}, } const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) @@ -44,7 +44,7 @@ it('uses provider input defaults for a model absent from the installed catalog', const onChange = vi.fn() render( en[key]} + probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]} onBusyChange={() => {}} operations={operations(() => Promise.resolve({ kind: 'found', models: [] }))} />) fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) @@ -60,7 +60,7 @@ it('inherits catalog inputs once an incomplete draft has a model id', async () = const props = { onChange, catalogProvider: 'openai', probe: { settingsNs: 'llm-pi-ai', provider: 'openai' }, - disabled: false, t: (key: keyof typeof en) => en[key], + disabled: false, t: (key: keyof typeof en) => en[key], onBusyChange: () => {}, operations: operations(() => Promise.resolve({ kind: 'found', models: [{ id: 'vision', inputModalities: ['text', 'image'] }], })), @@ -89,7 +89,7 @@ it('restores inherited image input after a failed catalog read is retried manual const onChange = vi.fn() render( en[key]} + probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]} onBusyChange={() => {}} operations={operations(discover)} />) await screen.findByText('Catalog unavailable') diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 57c61d702b..e482be4fbd 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -225,6 +225,12 @@ function openEditor(provider: string): void { fireEvent.click(summary) } +/** Open the add card from the section and switch it to the custom-API mode. */ +function openCustomMode(): void { + fireEvent.click(screen.getByRole('button', { name: en.add })) + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) +} + /** Open one model row's advanced fold, where the capacities live. */ function expandModel(index: number): void { fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`)) @@ -1417,27 +1423,40 @@ describe('hand-declared providers', () => { expect(buttonNamed(en.create).disabled).toBe(true) }) - it('closes the create card when an existing row is opened for editing', async () => { + it('closes the add card when an existing row is opened for editing', async () => { await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } }) - fireEvent.click(screen.getByRole('button', { name: en.customAdd })) - expect(screen.getByText(en.customTitle)).toBeTruthy() + openCustomMode() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() // Two cards at once would each be closable by the other: whichever one is // dismissed clears the shared state and discards the other's draft. openEditor('openai') - expect(screen.queryByText(en.customTitle)).toBeNull() + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() }) - it('reaches the card from the section and returns to the button on cancel', async () => { + it('reaches the custom form from the section and returns to the button on cancel', async () => { await mountSection() - fireEvent.click(screen.getByRole('button', { name: en.customAdd })) - expect(screen.getByText(en.customTitle)).toBeTruthy() + openCustomMode() + expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy() fireEvent.click(screen.getByText(en.cancel)) - await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) - expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() + await waitFor(() => { expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() }) + expect(screen.getByRole('button', { name: en.add })).toBeTruthy() + }) + + it('names each protocol by its product name and falls back to the identifier of an unknown one', () => { + mountCard({ protocols: [...PROTOCOLS, 'google-generative-ai'] }) + const protocol = screen.getByLabelText(en.customApi) + const labels = [...protocol.options].map(option => [option.value, option.textContent]) + expect(labels).toEqual([ + ['openai-completions', en.protocolOpenAiCompletions], + ['openai-responses', en.protocolOpenAiResponses], + ['anthropic-messages', en.protocolAnthropicMessages], + ['google-generative-ai', 'google-generative-ai'], + ]) }) it('refuses an unusable key on the field and blocks creation', () => { @@ -1608,15 +1627,15 @@ describe('API key field', () => { const { controller, mutate } = await mountSection() const load = vi.spyOn(controller, 'load') - fireEvent.click(screen.getByRole('button', { name: en.customAdd })) - fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + openCustomMode() + fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } }) + fireEvent.change(screen.getByRole('textbox', { name: en.baseUrl }), { target: { value: 'https://acme.test/v1' } }) fireEvent.click(screen.getByRole('button', { name: en.addModel })) fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) fireEvent.click(screen.getByText(en.create)) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) await waitFor(() => { expect(load).toHaveBeenCalledOnce() }) - expect(screen.queryByText(en.customTitle)).toBeNull() + expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 141c7e0474..faad4651ee 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 2797d6fd6be8f429043c8de1413f90e64bda3876 -README.zh.md: a04571ed6d76ca812aa0a70ce6f3c403ad80947d +README.md: 79c7253887cd580bdcc725054c0bbf5b2877e90b +README.zh.md: 77847e8ce7a9203a85fee360ec8a481e32e7e871 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 2797d6fd6b..79c7253887 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -51,10 +51,10 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. -- **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. -- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a ResourceGroup member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. +- **Complete bytes** — PDF, HTML, and common images call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. -When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. The dependency Resource is added after `readRelated` returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. +When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. @@ -86,7 +86,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. -The shared `documentFileBytes()` helper decodes ordinary file and converted PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. +The `documentFileBytes()` helper decodes converted Office PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index a04571ed6d..77847e8ce7 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -51,10 +51,10 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 -- **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 -- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。ResourceGroup 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 +- **完整字节** —— PDF、HTML 和常见图片调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 -开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。依赖 Resource 在 `readRelated` 返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 +开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 @@ -86,7 +86,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 -共享 `documentFileBytes()` 辅助函数将普通文件与转换后 PDF 的响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 +`documentFileBytes()` 辅助函数将 Office 转换后的 PDF 响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/face.ts index 17dda43da3..914c754278 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/face.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/face.ts @@ -101,7 +101,7 @@ interface TabReads { /** * Bind the preview's face to one paged read and one complete-byte read. * @param read - the bound `workspaceFiles.read` call. - * @param readAll - ordinary complete-byte Remote read. + * @param readAll - complete-byte workspace Remote read. * @param resources - shared metadata sources for the document and its dependencies. * @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own. */ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts index 50672fc97b..88ebcfda78 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts @@ -35,7 +35,7 @@ export function apply(ctx: Context): void { hooks: { interactivePreview: ctx.settingsScope.developerTools.enabled }, readRelated: (address, relativePath, signal) => { const file = hostFileOf(address) - return ctx.remote.workspaceFiles.readRelated(file.sessionId, file.path, relativePath, signal) + return ctx.remote.workspaceFiles.readBytes(file.sessionId, relativePath, { baseFile: file.path }, signal) }, }), }, HtmlBody, diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts b/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts index 5bbf23c94e..8421a1ab29 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts @@ -1,7 +1,6 @@ -/** Adapt a Remote relative read without changing its Session or Host path authority. */ +/** Adapt a relative workspace read without changing its Session or Host path authority. */ import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' -import type { WorkspaceFileBytes } from '@deepseek-ai/dsh-api-workspace-files/types' -import { documentFileBytes } from '../rpc.ts' +import type { DocumentFileBytes } from '../rpc.ts' import type { ReadHtmlRelative } from './pack.ts' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { hostFileOf } from '../rpc.ts' @@ -11,13 +10,13 @@ import { hostFileOf } from '../rpc.ts' * @param address - root HTML file address. * @param relativePath - decoded dependency path, resolved only by the Host. * @param signal - cancellation shared by the tab and packing operation. - * @returns the Remote complete-byte result, including declared failures. + * @returns the complete-byte result, including declared failures. */ -export type ReadHtmlRelated = (address: string, relativePath: string, signal: AbortSignal) => Promise> +export type ReadHtmlRelated = (address: string, relativePath: string, signal: AbortSignal) => Promise> /** * Bind a package reader to the original HTML file's address. - * @param readRelated - Remote reader using the Session in the root HTML address. + * @param readRelated - workspace reader using the Session in the root HTML address. * @param address - root HTML file address. * @param lifetime - tab lifetime. * @param addResource - subscribes to the dependency path reported by the Host. @@ -47,6 +46,6 @@ export function createReadHtmlRelative( throw new Error(result.error.message) } addResource(sessionFileAddress(file.sessionId, result.value.absolutePath)) - return documentFileBytes(result.value) + return result.value } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index c5edbc05a5..7c8b6907b2 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -25,7 +25,7 @@ import type { TextPreviewInjected } from './TextPreview.tsx' import { TextTitle } from './TextTitle.tsx' import { TEXTPREVIEW_ID, textDefinition } from './definition.ts' import { textFace } from './face.ts' -import { createReadPage, documentFileBytes } from './rpc.ts' +import { createReadPage } from './rpc.ts' import { createTextStore } from './store.ts' import { en, zh } from './locales.ts' import { DocumentPreviewRegistry } from './document/registry.ts' @@ -76,13 +76,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** * Required browser services: the tab registry, the slot registry, copy, and the - * Remote carrier with its `workspaceFiles` namespace. + * workspace Remote for bytes and paged text reads. */ export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'settingsScope', 'resources'] /** * Client plugin body: register the type, its dictionaries, its body, and its chip title. - * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face. + * @param ctx - client root context carrying the registry, slots, copy, and file readers. */ export function apply(ctx: ClientContext): void { const config = Config((globalThis as { __DSH_DOCUMENT_PREVIEW_CONFIG__?: unknown }).__DSH_DOCUMENT_PREVIEW_CONFIG__ ?? {}) @@ -95,10 +95,7 @@ export function apply(ctx: ClientContext): void { const store = createTextStore() const face = textFace( createReadPage(ctx.remote), - async (file, signal) => { - const result = await ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal) - return result.ok ? { ok: true, value: documentFileBytes(result.value) } : result - }, + (file, signal) => ctx.remote.workspaceFiles.readBytes(file.sessionId, file.path, {}, signal), ctx.resources, ) const source = { getSnapshot: previews.getSnapshot, subscribe: previews.subscribe } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 0672b5f1ed..098e5dd06c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-office-to-pdf/remote' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-api-workspace-files/remote' -import type {} from '@deepseek-ai/dsh-client-connection/client' import { documentFileBytes } from '../rpc.ts' import { failureLine } from '../failure-line.ts' import { documentTabInfoFactory } from '../document/contract.ts' @@ -81,7 +80,9 @@ export function apply(ctx: Context, config: Config['office']): void { } const createCache = () => new OfficePreviewCache( async (file, signal) => { - const authorized = await scope.remote.workspaceFiles.readBytes(file.sessionId, file.path, { offset: 0, length: 1 }, signal) + const authorized = await scope.remote.workspaceFiles.readBytes( + file.sessionId, file.path, { range: { offset: 0, length: 1 } }, signal, + ) signal.throwIfAborted() if (!authorized.ok) return authorized const metadata = await scope.remote.workspaceFiles.stat(file.sessionId, file.path, signal) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts b/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts index d609278c4f..8289374cdc 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts @@ -81,23 +81,23 @@ export function createReadPage(remote: WorkspaceFilesReadRemote): ReadWorkspaceF return (sessionId, path, offset, signal) => remote.workspaceFiles.read(sessionId, path, { offset }, signal) } -/** Complete document bytes borrowed read-only by renderers; copy before transferring to a Worker. */ -export type DocumentFileBytes = Omit & { readonly data: Uint8Array } +/** Document bytes borrowed read-only by renderers; copy before transferring to a Worker. */ +export type DocumentFileBytes = WorkspaceFileBytes> /** * Read a complete file through the Host endpoint. * @param file - Session and path decoded from the tab address. * @param signal - owning tab lifetime. - * @returns complete binary bytes, including declared failures. + * @returns complete native bytes, including declared failures. */ export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Promise> /** - * Decode one successful Remote byte result for document renderers. - * @param file - Host byte result with base64 data. + * Decode the Office conversion Remote's base64 PDF response. + * @param file - converted PDF metadata and base64 contents. * @returns the same metadata with native bytes; malformed base64 throws. */ -export function documentFileBytes(file: WorkspaceFileBytes): DocumentFileBytes { +export function documentFileBytes(file: Omit & { readonly data: string }): DocumentFileBytes { const binary = atob(file.data) const data = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index++) data[index] = binary.charCodeAt(index) diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 976da58bbc..5e82256d60 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -7,6 +7,8 @@ * and face — and that every registration is gone after dispose, which is what * makes a reload safe. */ +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -29,10 +31,9 @@ import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx' import { PDF_BODY_ID } from '../src/client/pdf/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' import { en, zh } from '../src/client/locales.ts' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { textFace } from '../src/client/face.ts' import type { TextStore } from '../src/client/store.ts' -import { FILE, SESSION, TAB_ID, createResources, page } from './fixtures.client.ts' +import { FILE, SESSION, TAB_ID, byteResult, createResources, page } from './fixtures.client.ts' interface Recorded { name: string @@ -65,21 +66,17 @@ async function boot() { return () => { dictionaries.delete(ns) } }), } - const workspaceFiles = { - read: vi.fn().mockResolvedValue(page(1, ['first'], true)), - readAll: vi.fn().mockResolvedValue({ - ok: true, value: { absolutePath: '/workspace/notes.md', version: 'v1', offset: 0, data: 'AAH/', eof: true }, - }), - } + const read = vi.fn().mockResolvedValue(page(1, ['first'], true)) + const readBytes = vi.fn(async () => byteResult()) ctx.provide('sidebarRightTabs', tabs as never) ctx.provide('slots', slots as never) ctx.provide('locale', locale as never) - ctx.provide('remote', { workspaceFiles } as never) - ctx.provide('remote.workspaceFiles', workspaceFiles as never) + ctx.provide('remote', { workspaceFiles: { read, readBytes } } as never) + ctx.provide('remote.workspaceFiles', { read, readBytes } as never) const fiber = ctx.plugin({ inject: [...inject], apply }) onTestFinished(async () => { await fiber.dispose() }) await fiber.await() - return { tabs, registered, dictionaries, fiber, workspaceFiles } + return { tabs, registered, dictionaries, fiber, read, readBytes } } describe('ui-sidebar-documentpreview apply', () => { @@ -115,8 +112,8 @@ describe('ui-sidebar-documentpreview apply', () => { expect(dictionaries.size).toBe(0) }) - it('injects ordinary Remote reads independently of resource metadata', async () => { - const { registered, workspaceFiles } = await boot() + it('injects paged and byte Remote reads independently of resource metadata', async () => { + const { registered, read, readBytes } = await boot() const registration = registered.find(entry => entry.component === TextPreview) if (registration === undefined) throw new Error('missing preview registration') const instance = (registration.store as TextStore).create() @@ -124,17 +121,16 @@ describe('ui-sidebar-documentpreview apply', () => { const controller = new AbortController() onTestFinished(() => { controller.abort() }) face.loadPage(TAB_ID, FILE, 1, controller.signal, 'v1') - await workspaceFiles.read.mock.results[0]?.value - expect(workspaceFiles.read).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, { offset: 1 }, controller.signal) + await read.mock.results[0]?.value + expect(read).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, { offset: 1 }, controller.signal) expect(instance.getSnapshot().byTab[TAB_ID]?.pages[1]?.text).toBe('first') face.loadAll(TAB_ID, FILE, controller.signal, 'v1') - await workspaceFiles.readAll.mock.results[0]?.value - expect(workspaceFiles.readAll).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, expect.any(AbortSignal)) - await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.complete?.data).toEqual(new Uint8Array([0, 1, 255])) - const failure = new RemoteError('workspace-file/not-found', 'File missing', { path: FILE.path }) - workspaceFiles.readAll.mockResolvedValueOnce({ ok: false, error: failure }) + await vi.waitFor(() => { expect(instance.getSnapshot().byTab[TAB_ID]?.complete).toBeDefined() }) + expect(readBytes).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, {}, expect.any(AbortSignal)) + expect(instance.getSnapshot().byTab[TAB_ID]?.complete?.data).toEqual(new Uint8Array([0, 1, 255])) + readBytes.mockResolvedValueOnce({ ok: false, error: new RemoteError('workspace-file/not-found', 'File missing', { path: FILE.path }) }) face.reloadAll(TAB_ID, FILE, controller.signal, 'v2') - await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.failure).toBe(failure) + await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.failure).toMatchObject({ code: 'workspace-file/not-found', message: 'File missing' }) expect(instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index f938754af5..f3eeace3c8 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -1,18 +1,19 @@ // @vitest-environment jsdom /** Document extension registration and dispatch through the production Sidebar and Slot renderer. */ +import { byteResult } from './fixtures.client.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { apply as resourcesApply, inject as resourcesInject } from '@deepseek-ai/dsh-client-resources/src/client/index.ts' import { apply as sidebarApply, inject as sidebarInject } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/index.ts' import { apply, inject } from '../src/client/index.ts' import type { DocumentPreviewProps } from '../src/client/document/contract.ts' import type { DocumentLoadMode } from '../src/client/document/registry.ts' +import type { WorkspaceFilesReadRemote } from '../src/client/rpc.ts' import { PLAIN_BODY_ID } from '../src/client/text/index.ts' const SESSION = 'documents' as SessionId @@ -49,15 +50,12 @@ async function boot() { await rt.sessions.add({ id: SESSION }) await rt.sessions.retainFor(rt.ctx, SESSION, { source: 'mainView' }).ready await rt.mount({ inject: [...resourcesInject], apply: resourcesApply }) - const read = vi.fn().mockImplementation(async (_sessionId, _path, range) => ({ + const read = vi.fn().mockImplementation(async (_sessionId, _path, range) => ({ ok: true, value: { absolutePath: '/host/notes', version: 'v1', bytes: 18, offset: range.offset ?? 1, text: range.offset === 3 ? 'third' : 'first\nsecond', lines: range.offset === 3 ? 1 : 2, eof: range.offset === 3 }, })) - const bytes = vi.fn().mockResolvedValue({ - ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true }, - }) - const workspaceFiles = { read, readAll: bytes } - rt.remote.provideNamespaces({ workspaceFiles }) + const bytes = vi.fn(async () => byteResult(new TextEncoder().encode('all'))) + rt.remote.provideNamespaces({ workspaceFiles: { read, readBytes: bytes } }) rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'file', open: async function* (_address, { signal }) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts index 2a8a85e183..0eead1b061 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts @@ -237,6 +237,7 @@ describe('textFace', () => { expect(tab()?.complete).toBeUndefined() await settleAll(result) expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: false, complete: result.value, version: 'v1', eof: true, pages: {} }) + expect(tab()?.complete?.data).toBe(result.value.data) }) it('records a complete-read failure and clears it when the read is retried', async () => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index d34bd4bc5a..d71d1167b1 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -21,7 +21,7 @@ import type { WorkspaceFileStat, WorkspaceFileText } from '@deepseek-ai/dsh-api- import type { TextPreviewProps } from '../src/client/TextPreview.tsx' import { textFace } from '../src/client/face.ts' import type { TextInjected } from '../src/client/face.ts' -import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts' +import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts' import { createTextStore } from '../src/client/store.ts' import type { TextStore } from '../src/client/store.ts' import type { DocumentPreviewProps } from '../src/client/document/contract.ts' @@ -189,3 +189,8 @@ export function harness(script: Record> setFailure(failure) { current.failure = failure; refresh() }, } } + +/** Native byte result returned by the generated workspace Remote. */ +export function byteResult(data = new Uint8Array([0, 1, 255])): RemoteResult { + return { ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, eof: true, bytes: data.byteLength, data } } +} diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts index bcf14b5c6a..e2f7a2eff6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts @@ -1,3 +1,4 @@ +import { byteResult } from './fixtures.client.ts' /** HTML metadata and keyed slot contributions share one identity and unwind with their fiber. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -36,8 +37,8 @@ describe('HTML registration', () => { const registry = new DocumentPreviewRegistry() const dictionaries = new Map() const bodies = new Map() - const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { data: '' } }) - ctx.provide('remote', { workspaceFiles: { readRelated } } as never) + const readRelated = vi.fn(async () => byteResult()) + ctx.provide('remote', { workspaceFiles: { readBytes: readRelated } } as never) const register = vi.fn((options: Registration, body: unknown) => { bodies.set(options.key, body) return () => { bodies.delete(options.key) } @@ -62,9 +63,9 @@ describe('HTML registration', () => { expect(typeof injected?.readRelated).toBe('function') const signal = new AbortController().signal await injected?.readRelated('dsh-resource://file/session/explicit-session/sub/index.html', '../app.js', signal) - expect(readRelated).toHaveBeenLastCalledWith('explicit-session', 'sub/index.html', '../app.js', signal) + expect(readRelated).toHaveBeenLastCalledWith('explicit-session', '../app.js', { baseFile: 'sub/index.html' }, signal) await injected?.readRelated(sessionFileAddress('absolute-session', '/workspace/index.html'), './app.js', signal) - expect(readRelated).toHaveBeenLastCalledWith('absolute-session', '/workspace/index.html', './app.js', signal) + expect(readRelated).toHaveBeenLastCalledWith('absolute-session', './app.js', { baseFile: '/workspace/index.html' }, signal) expect(() => injected?.readRelated('dsh-resource://file/absolute/workspace/index.html', './app.js', signal)) .toThrow('not a session file address') expect(readRelated).toHaveBeenCalledTimes(2) diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index b136e6791b..8ed6f439a2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -90,7 +90,7 @@ describe('HtmlBody', () => { h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'root-v1', data, offset: 0, eof: true } }) const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: `/workspace/asset.${extension}`, version: 'asset-v1', offset: 0, eof: true, - data: btoa(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'), + data: utf8(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'), } }) const definition = { ...htmlBodyDefinition(() => 'HTML'), extensions: ['md'] } let interactive = true @@ -111,7 +111,7 @@ describe('HtmlBody', () => { expect(create).toHaveBeenCalledOnce() readRelated.mockResolvedValueOnce({ ok: true, value: { absolutePath: `/workspace/asset.${extension}`, version: 'asset-v2', offset: 0, eof: true, - data: btoa(extension === 'css' ? 'body { color: blue }' : 'window.loaded = false'), + data: utf8(extension === 'css' ? 'body { color: blue }' : 'window.loaded = false'), } }) act(() => { dependency.set(metadata('asset-v2')) }) await waitFor(() => { expect(screen.getByTitle(en.frame)).not.toBe(previous) }) @@ -161,7 +161,7 @@ describe('HtmlBody', () => { const preference = new DeveloperToolsPreference(host.scope) const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/workspace/asset.js', version: 'asset-v1', offset: 0, eof: true, - data: btoa('window.loaded = true'), + data: utf8('window.loaded = true'), } }) const scripted: HtmlBodyProps = { ...props('

Preview

'), @@ -252,7 +252,7 @@ describe('HtmlBody', () => { const signal = new AbortController().signal const bytes = vi.fn().mockResolvedValue({ ok: true, - value: { absolutePath: '/workspace/app.js', data: btoa('window.ready=true'), version: 'v1', offset: 0, eof: true }, + value: { absolutePath: '/workspace/app.js', data: utf8('window.ready=true'), version: 'v1', offset: 0, eof: true }, }) const useResource = vi.fn().mockReturnValue({ value: undefined }) const initial = { @@ -275,7 +275,7 @@ describe('HtmlBody', () => { const signal = new AbortController().signal const bytes = vi.fn().mockResolvedValue({ ok: true, - value: { absolutePath: '/workspace/app.js', version: 'v1', bytes: 16, offset: 0, data: btoa('window.ready=1'), eof: true }, + value: { absolutePath: '/workspace/app.js', version: 'v1', bytes: 16, offset: 0, data: utf8('window.ready=1'), eof: true }, }) const useResource = vi.fn(() => ({ value: { version: 'v1' } })) const initial = { diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts index d043a1a579..3c6e65e2ec 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts @@ -1,4 +1,4 @@ -/** HTML URL decoding stays local; ordinary Remote reads leave path resolution and authorization to the Host. */ +/** HTML URL decoding stays local; workspace reads leave path resolution and authorization to the Host. */ import { describe, expect, it, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' @@ -8,14 +8,14 @@ import type { ReadHtmlRelated } from '../src/client/html/read-relative.ts' const ADDRESS = 'dsh-resource://file/session/html/sub/index.html' describe('HTML relative file reader', () => { - it('decodes a relative URL once and converts the Remote result to native bytes', async () => { - const value = { absolutePath: '/workspace/a b.js', data: btoa('x'), version: 'v1', offset: 0, bytes: 1, eof: true } + it('decodes a relative URL once and preserves the returned bytes', async () => { + const value = { absolutePath: '/workspace/a b.js', data: new Uint8Array([120]), version: 'v1', offset: 0, bytes: 1, eof: true } const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) const tab = new AbortController() const loading = new AbortController() const addResource = vi.fn() const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal, addResource) - await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toEqual({ ...value, data: new Uint8Array([120]) }) + await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toBe(value) const signal = readRelated.mock.calls[0]?.[2] expect(readRelated).toHaveBeenCalledExactlyOnceWith(ADDRESS, '../a b.js', signal) expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', value.absolutePath)) @@ -25,7 +25,7 @@ describe('HTML relative file reader', () => { }) it('observes the Host-resolved dependency instead of a symlink and parent-segment spelling', async () => { - const value = { absolutePath: '/workspace/style.css', data: btoa('x'), version: 'v1', offset: 0, eof: true } + const value = { absolutePath: '/workspace/style.css', data: new TextEncoder().encode('x'), version: 'v1', offset: 0, bytes: 1, eof: true } const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) const addResource = vi.fn() const signal = new AbortController().signal @@ -85,7 +85,7 @@ describe('HTML relative file reader', () => { const result = read('./late.js', loading.signal) const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) loading.abort() - pending.resolve({ ok: true, value: { absolutePath: '/workspace/late.js', version: 'v1', bytes: 1, offset: 0, data: 'AQ==', eof: true } }) + pending.resolve({ ok: true, value: { absolutePath: '/workspace/late.js', version: 'v1', bytes: 1, offset: 0, data: new Uint8Array([1]), eof: true } }) await rejected }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index 98a980f49d..ffa492509f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -55,7 +55,7 @@ async function harness(config: Partial = {}, missing?: 'remote const render = vi.fn().mockResolvedValue(converted) const rendererGeneration = vi.fn().mockResolvedValue({ ok: true, value: generation }) const stat = vi.fn().mockResolvedValue({ ok: true, value: source }) - const readBytes = vi.fn().mockResolvedValue({ ok: true, value: source }) + const readBytes = vi.fn().mockResolvedValue({ ok: true, value: { ...source, data: pdf.subarray(0, 1), eof: false } }) const removeNotice = vi.fn() const recorded: { options: { name: string; store: OfficeStore; inject: (id: SessionId, actions: ReturnType['actions']) => OfficeBodyInjected }; component: unknown }[] = [] const register = vi.fn((options: typeof recorded[number]['options'], component: unknown) => { recorded.push({ options, component }); return removeNotice }) @@ -112,7 +112,7 @@ it('requests a Host PDF with source identity and borrows the same binary cache r expect(await h.read()).toBe(result) expect(h.stat).toHaveBeenCalledTimes(2) expect(h.readBytes).toHaveBeenCalledTimes(2) - expect(h.readBytes).toHaveBeenCalledWith(file.sessionId, file.path, { offset: 0, length: 1 }, expect.any(AbortSignal)) + expect(h.readBytes).toHaveBeenCalledWith(file.sessionId, file.path, { range: { offset: 0, length: 1 } }, expect.any(AbortSignal)) expect(h.render).toHaveBeenCalledOnce() } finally { await h.close() } }) @@ -147,8 +147,8 @@ it('refuses Client cached bytes when metadata still succeeds but the read probe try { await h.read() const failure = new Error('read denied') - h.readBytes.mockRejectedValueOnce(failure) - await expect(h.read()).rejects.toBe(failure) + h.readBytes.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', failure.message, {}) }) + expect(await h.read()).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: failure.message } }) expect(h.render).toHaveBeenCalledOnce() expect(h.stat).toHaveBeenCalledOnce() } finally { await h.close() } @@ -160,7 +160,7 @@ it('returns a declared authorization failure without consulting metadata or cach await h.read() const denied = { ok: false as const, error: new RemoteError('workspace-file/not-found', 'File missing', { path: file.path }) } h.readBytes.mockResolvedValueOnce(denied) - expect(await h.read()).toBe(denied) + expect(await h.read()).toMatchObject(denied) expect(h.stat).toHaveBeenCalledOnce() expect(h.render).toHaveBeenCalledOnce() } finally { await h.close() } diff --git a/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts index 0657e1aaf4..a7a6a74f5e 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' -import { createReadPage, documentFileBytes, hostFileOf } from '../src/client/rpc.ts' +import { createReadPage, hostFileOf } from '../src/client/rpc.ts' import type { ReadWorkspaceFilePage, WorkspaceFilesReadRemote } from '../src/client/index.ts' import { ADDRESS, FILE, PATH, SESSION, page } from './fixtures.client.ts' @@ -39,17 +39,3 @@ describe('createReadPage', () => { expect(read).toHaveBeenCalledWith(SESSION, PATH, { offset: 4 }, signal) }) }) - -describe('documentFileBytes', () => { - it.each(['', 'AAH/'])('decodes complete Remote bytes without changing metadata (%j)', (data) => { - const file = { absolutePath: '/workspace/a.bin', version: 'v1', bytes: 3, offset: 0, data, eof: true } - const result = documentFileBytes(file) - expect(result).toEqual({ ...file, data: data === '' ? new Uint8Array() : new Uint8Array([0, 1, 255]) }) - expect(result.data.buffer).toBeInstanceOf(ArrayBuffer) - expect(file.data).toBe(data) - }) - - it('rejects malformed wire base64', () => { - expect(() => documentFileBytes({ absolutePath: '/workspace/a.bin', version: 'v1', offset: 0, data: '!!!', eof: true })).toThrow() - }) -}) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 5a643c235a..26a6af0825 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -182,13 +182,31 @@ outline: none; } -/* Hovering a session row reveals a title wider than its cell: Rows.tsx scrolls - it to its end while the pointer rests on the row — smoothly, through the - scroll-behavior here — and returns it to the start in one step when the - pointer leaves. */ +/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls + it at a constant speed to its far edge while the pointer rests on the row, + holds it there, and returns it to the start in one step when the pointer + leaves. The motion is scripted frame by frame (reduced motion jumps), so no + scroll-behavior applies here. */ .sessionRow .title { flex: 1; - scroll-behavior: smooth; +} + +/* While the marquee travels, both clipping edges fade in over 12px instead of + hard-cutting a character: the left once the title has moved off its start + (data-scrolled), the right while text still remains beyond the cell + (data-clipped) — at the far edge the right fade lifts so the final character + reads at full strength. The mask rides the title span itself, so the status + slot and every other leading cell keep their full color. */ +.sessionRow .title[data-scrolled] { + mask-image: linear-gradient(to right, transparent, #000 12px); +} + +.sessionRow .title[data-clipped] { + mask-image: linear-gradient(to left, transparent, #000 12px); +} + +.sessionRow .title[data-scrolled][data-clipped] { + mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent); } .meta { @@ -287,7 +305,7 @@ } /* The unclipped hover state drops the ellipsis, which would otherwise cover - the characters the scroll reveal reached. Guarded because a touch tap + the characters the marquee reached. Guarded because a touch tap latches `:hover` on the row after the title has already returned: an unguarded rule would strand that row without its ellipsis. */ @media (hover: hover) { @@ -418,9 +436,4 @@ animation: none; transition: none; } - - /* The title reveal jumps to the far edge instead of gliding there. */ - .sessionRow .title { - scroll-behavior: auto; - } } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index ee35919e1f..a3cfa707de 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,7 +2,7 @@ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only, and a session row's clipped - * title is scrolled programmatically while the row is hovered. Workspace row + * title marquees programmatically while the row is hovered. Workspace row * menus are visual-only except Rename/Delete. A Session row's "..." menu and * its hover buttons are the `sidebar.workspaces.session.menu.item` and * `sidebar.workspaces.session.row.action` lists, rendered through the @@ -11,6 +11,7 @@ * session and workspace hover cards are suppressed while a menu is open. */ import { useEffect, useMemo, useRef, useState } from 'react' +import type { RefObject } from 'react' import clsx from 'clsx' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { @@ -37,31 +38,84 @@ function displayTitle(node: SessionNode, t: RowTranslate): string { accidental jitter, so the title stays put. */ const MIN_TITLE_REVEAL_PX = 8 +/* Marquee travel speed: slow enough to read the text as it passes. */ +const TITLE_MARQUEE_PX_PER_MS = 0.03 + /** - * Reveal a title wider than its one-line cell while its row is hovered: the - * title clips its own text, so the far edge (a fork's incremented title, for - * example) is reachable by scrolling the element to its end. Overflow of at - * most {@link MIN_TITLE_REVEAL_PX} stays put — a barely-clipped title moving a - * few pixels reads as jitter, not a reveal. Leaving returns the title to the - * start in one step, because the resting ellipsis and the narrowed cell would - * otherwise meet the text while it travelled back. The stylesheet decides - * whether either move glides or jumps. + * Place the title's scroll position and publish the stylesheet's fade-mask + * hooks: `data-scrolled` while the title has left its start (left fade) and + * `data-clipped` while text remains beyond the right edge (right fade). * @param title - the row's clipping title element. - * @param revealed - whether the pointer is on the row. + * @param left - scroll offset in CSS pixels. + * @param range - the title's maximum scroll offset in CSS pixels. */ -function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void { - /* v8 ignore next -- defensive: the title span renders unconditionally. */ - if (title === null) return - if (revealed) { - const range = title.scrollWidth - title.clientWidth - if (range <= MIN_TITLE_REVEAL_PX) return - title.scrollLeft = range - return - } +function placeTitle(title: HTMLSpanElement, left: number, range: number): void { // jsdom implements no scrollTo; the lane's direct assignment is instant there - // anyway, so both paths land on the same resting position. + // anyway, so both paths land on the same position. + if (typeof title.scrollTo === 'function') title.scrollTo({ left, behavior: 'instant' }) + else title.scrollLeft = left + if (left > 0) title.dataset.scrolled = '' + else delete title.dataset.scrolled + if (left < range) title.dataset.clipped = '' + else delete title.dataset.clipped +} + +/** + * Return the title to its resting state: scrolled to the start with both fade + * masks off, so the resting ellipsis renders at full strength. + * @param title - the row's clipping title element. + */ +function restTitle(title: HTMLSpanElement): void { if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' }) else title.scrollLeft = 0 + delete title.dataset.scrolled + delete title.dataset.clipped +} + +/** + * Marquee a title wider than its one-line cell while its row is hovered: the + * title clips its own text, so entering crawls it at a constant speed until the + * far edge (a fork's incremented title, for example) is in view, then rests + * there under the pointer. Overflow of at most {@link MIN_TITLE_REVEAL_PX} + * stays put — a barely-clipped title moving a few pixels reads as jitter, not a + * reveal. Leaving returns the title to the start in one step, because the + * resting ellipsis and the narrowed cell would otherwise meet the text while it + * travelled back. Reduced motion jumps to the far edge instead of crawling. + * @param title - ref to the row's clipping title element. + * @returns stable pointer enter/leave handlers for the row. + */ +function useTitleMarquee(title: RefObject): { enter: () => void; leave: () => void } { + const frame = useRef(0) + useEffect(() => () => { cancelAnimationFrame(frame.current) }, []) + return useMemo(() => ({ + enter: (): void => { + /* v8 ignore next -- defensive: the title span renders unconditionally. */ + if (title.current === null) return + const element = title.current + const range = element.scrollWidth - element.clientWidth + if (range <= MIN_TITLE_REVEAL_PX) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + placeTitle(element, range, range) + return + } + cancelAnimationFrame(frame.current) + let previous: number | undefined + let position = 0 + const step = (now: DOMHighResTimeStamp): void => { + position += previous === undefined ? 0 : (now - previous) * TITLE_MARQUEE_PX_PER_MS + previous = now + placeTitle(element, Math.min(position, range), range) + if (position < range) frame.current = requestAnimationFrame(step) + } + frame.current = requestAnimationFrame(step) + }, + leave: (): void => { + cancelAnimationFrame(frame.current) + /* v8 ignore next -- defensive: the title span renders unconditionally. */ + if (title.current === null) return + restTitle(title.current) + }, + }), [title]) } /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ @@ -504,6 +558,7 @@ export function SessionNodeItem({ const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]) const rowRef = useRef(null) const titleRef = useRef(null) + const marquee = useTitleMarquee(titleRef) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -524,8 +579,8 @@ export function SessionNodeItem({ aria-selected={selected} aria-description={row.archived ? t('toast.archivedNotOpenable') : undefined} onClick={() => { onOpen(node.id) }} - onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} - onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} + onPointerEnter={marquee.enter} + onPointerLeave={marquee.leave} draggable={draggable} onDragStart={!draggable ? undefined diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 38d4ee36a3..bf4191366b 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -108,13 +108,20 @@ describe('WorkspaceBrowser.module.css list', () => { .toBe('var(--dsw-alias-interactive-bg-hover)') }) - it('reveals a clipped session title by scrolling it on row hover', () => { - // Smooth versus reduced motion is pinned as a computed style in - // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges - // same-selector rules across media queries, so the reduce override would - // mask the smooth declaration here. + it('marquees a clipped session title on row hover', () => { + // The crawl itself is scripted in Rows.tsx frame by frame, so the title + // declares no scroll-behavior; the stylesheet keeps the hovered cell + // unclipped and fades whichever edges cut text mid-travel, on the title + // span itself so the status slot beside it keeps its full color. expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') + expect(rowDeclarations('.sessionRow .title')?.get('scroll-behavior')).toBeUndefined() expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') + expect(rowDeclarations('.sessionRow .title[data-scrolled]')?.get('mask-image')) + .toBe('linear-gradient(to right, transparent, #000 12px)') + expect(rowDeclarations('.sessionRow .title[data-clipped]')?.get('mask-image')) + .toBe('linear-gradient(to left, transparent, #000 12px)') + expect(rowDeclarations('.sessionRow .title[data-scrolled][data-clipped]')?.get('mask-image')) + .toBe('linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent)') }) it('pins both rail controls to the shared left anchor during the column slide', () => { diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index f41199824f..41e1b6e037 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -208,66 +208,136 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) - it('reveals a clipped session title by scrolling it while the row is hovered', () => { - const node: SessionNode = { - id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - pinned: false, archived: false, + it('marquees a clipped session title at a constant speed while the row is hovered', () => { + vi.useFakeTimers() + // jsdom implements no matchMedia; the stub answers the reduced-motion probe. + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + try { + const node: SessionNode = { + id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + pinned: false, archived: false, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + // jsdom lays out nothing: the title's clipped geometry is stated outright. + const geometry = (scrollWidth: number, clientWidth: number): void => { + Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) + } + + geometry(320, 180) + fireEvent.pointerEnter(row) + // The first frame only records the clock; travel begins on the second. + vi.advanceTimersByTime(16) + expect(title.scrollLeft).toBe(0) + expect(title.hasAttribute('data-scrolled')).toBe(false) + expect(title.hasAttribute('data-clipped')).toBe(true) + // Constant 30px/s: 1600ms of frames crawl 48px, and the moved title + // publishes both fade-mask hooks — off its start, short of its far edge. + vi.advanceTimersByTime(1600) + expect(title.scrollLeft).toBeCloseTo(48, 5) + expect(title.hasAttribute('data-scrolled')).toBe(true) + expect(title.hasAttribute('data-clipped')).toBe(true) + // The crawl clamps at the far edge and rests there under the pointer; + // the right fade lifts so the final character reads at full strength. + vi.advanceTimersByTime(10_000) + expect(title.scrollLeft).toBe(140) + expect(title.hasAttribute('data-clipped')).toBe(false) + fireEvent.pointerLeave(row) + expect(title.scrollLeft).toBe(0) + expect(title.hasAttribute('data-scrolled')).toBe(false) + expect(title.hasAttribute('data-clipped')).toBe(false) + + // A title that fits has no scroll range: hovering leaves it at its start. + geometry(180, 180) + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(1000) + expect(title.scrollLeft).toBe(0) + fireEvent.pointerLeave(row) + + // Overflow at the 8px jitter threshold also stays put. + geometry(188, 180) + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(1000) + expect(title.scrollLeft).toBe(0) + fireEvent.pointerLeave(row) + + // One pixel past the threshold crawls to its 9px extent. + geometry(189, 180) + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(1000) + expect(title.scrollLeft).toBe(9) + } finally { + vi.useRealTimers() + vi.unstubAllGlobals() } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - // jsdom lays out nothing: the title's clipped geometry is stated outright. - const geometry = (scrollWidth: number, clientWidth: number): void => { - Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) - Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) - } - - geometry(320, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(140) - fireEvent.pointerLeave(row) - expect(title.scrollLeft).toBe(0) - - // A title that fits has no scroll range: hovering leaves it at its start. - geometry(180, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(0) - fireEvent.pointerLeave(row) - - // Overflow at the 8px jitter threshold also stays put. - geometry(188, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(0) - fireEvent.pointerLeave(row) - - // One pixel past the threshold reveals. - geometry(189, 180) - fireEvent.pointerEnter(row) - expect(title.scrollLeft).toBe(9) }) - it('returns a revealed title to its start in one step', () => { - const node: SessionNode = { - id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, - running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, - pinned: false, archived: false, - } - render( - , - ) - const row = screen.getByRole('treeitem') - const title = screen.getByText(node.title) - const scrollTo = vi.fn() - Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) + it('jumps a clipped title to its far edge under reduced motion', () => { + vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))) + try { + const node: SessionNode = { + id: sid('reduced-motion'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + pinned: false, archived: false, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + Object.defineProperty(title, 'scrollWidth', { value: 320, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: 180, configurable: true }) - fireEvent.pointerEnter(row) - fireEvent.pointerLeave(row) - // Browsers receive the explicit instant behavior that the stylesheet's - // smooth scroll-behavior would otherwise override. - expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' }) + fireEvent.pointerEnter(row) + expect(title.scrollLeft).toBe(140) + expect(title.hasAttribute('data-scrolled')).toBe(true) + expect(title.hasAttribute('data-clipped')).toBe(false) + fireEvent.pointerLeave(row) + expect(title.scrollLeft).toBe(0) + expect(title.hasAttribute('data-scrolled')).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + + it('returns a marqueed title to its start in one step and cancels the crawl', () => { + vi.useFakeTimers() + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + try { + const node: SessionNode = { + id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + pinned: false, archived: false, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + Object.defineProperty(title, 'scrollWidth', { value: 320, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: 180, configurable: true }) + const scrollTo = vi.fn() + Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) + + fireEvent.pointerEnter(row) + vi.advanceTimersByTime(160) + fireEvent.pointerLeave(row) + // Browsers receive the explicit instant behavior so mid-crawl positions + // never ease; the return is one step. + expect(scrollTo).toHaveBeenLastCalledWith({ left: 0, behavior: 'instant' }) + // Leaving cancels the crawl rather than letting it finish under the mask. + const settled = scrollTo.mock.calls.length + vi.advanceTimersByTime(1000) + expect(scrollTo.mock.calls.length).toBe(settled) + } finally { + vi.useRealTimers() + vi.unstubAllGlobals() + } }) it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => { diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml index 1395353c93..8bd8e62df0 100644 --- a/packages/context/file-reference-local/README.i18n.yaml +++ b/packages/context/file-reference-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/file-reference-local/README.md -README.md: efeebdbd3e113597cc93dfe4d6c379e16b324d93 -README.zh.md: f286f92d6e6ec76afc9919db9f81c73e4ace8d04 +README.md: b80f5863e2cae166277d7672085bd1a9742e6cf3 +README.zh.md: 343d0950986df8e071c307314ce2e860168c4308 diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md index efeebdbd3e..b80f5863e2 100644 --- a/packages/context/file-reference-local/README.md +++ b/packages/context/file-reference-local/README.md @@ -105,7 +105,7 @@ When the addressed agent has an effective `read` tool, the provider contributes ##### File-reference instruction ```markdown -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. ``` #### Token effect diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md index f286f92d6e..343d095098 100644 --- a/packages/context/file-reference-local/README.zh.md +++ b/packages/context/file-reference-local/README.zh.md @@ -105,7 +105,7 @@ agent(智能体)及宿主 UI 可以用各 agent 本地工作区中经过排 ##### 文件引用指令 ```markdown -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. ``` #### Token 影响 diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml index 404975fb63..6d1e7a991a 100644 --- a/packages/context/file-reference/README.i18n.yaml +++ b/packages/context/file-reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/file-reference/README.md -README.md: ce84a91e96988fb662ee665707d1ce2ad1651327 -README.zh.md: ffdce668dd1b4a5a39a076d6abc998ec6db2ce92 +README.md: 3b759a88bd9ebb59fbf3d039756e66ad6901a893 +README.zh.md: d7dedc46365d70740480167cdfa0432f494d4563 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md index ce84a91e96..3b759a88bd 100644 --- a/packages/context/file-reference/README.md +++ b/packages/context/file-reference/README.md @@ -29,7 +29,7 @@ Choose this package when a host-backed UI (web or terminal) should offer `@file` ### Mention grammar -An `@path` token at the start of input or after whitespace triggers completion; an `@` inside another token, such as an email address, does not. `@"path with spaces"` opens a quoted mention, and a directory candidate keeps that quote open after its trailing slash so completion can descend another level. The formatter rejects paths with control characters or embedded quotes that the grammar cannot represent safely. +An `@path` token at the start of input or after whitespace triggers completion; an `@` inside another token, such as an email address, does not. `@"path with spaces"` opens a quoted mention, and a directory candidate keeps that quote open after its trailing slash so completion can descend another level. Completed directory chips close the quote after the trailing slash. Relative mentions resolve from the workspace root; absolute mentions retain the host path. The formatter rejects paths with control characters or embedded quotes that the grammar cannot represent safely. ### Getting candidates diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md index ffdce668dd..d7dedc4636 100644 --- a/packages/context/file-reference/README.zh.md +++ b/packages/context/file-reference/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### mention 语法 -输入开头或空白后的 `@path` token 会触发补全;其他 token 内部的 `@`(如电子邮件地址)不会。`@"path with spaces"` 打开带引号的 mention,目录候选在其尾斜杠后保持引号打开,使补全可以继续深入下一层。格式化器会拒绝含有语法无法安全表示的控制字符或内嵌引号的路径。 +输入开头或空白后的 `@path` token 会触发补全;其他 token 内部的 `@`(如电子邮件地址)不会。`@"path with spaces"` 打开带引号的 mention,目录候选在其尾斜杠后保持引号打开,使补全可以继续深入下一层。格式化器会拒绝含有语法无法安全表示的控制字符或内嵌引号的路径。 已完成的目录 chip 在末尾斜杠之后闭合引号。相对引用从工作区根目录解析,绝对引用保留主机路径。 ### 获取候选 diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts index bf1025714f..d6b30e783e 100644 --- a/packages/context/file-reference/src/index.ts +++ b/packages/context/file-reference/src/index.ts @@ -14,7 +14,7 @@ export type { ActiveAtToken } from './grammar.ts' export type { FileReferenceCandidate } from './types.ts' /** Model guidance for path-only references selected by a user interface. */ -export const FILE_REFERENCE_PROMPT = 'Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces.' +export const FILE_REFERENCE_PROMPT = 'Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces.' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 8a503e803f..bfbfe913e9 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 7c3073aefb90fa2ec74d521d6c9d97aff5f74ace -README.zh.md: 44621a0b02eadfc574f6f6b924f64cfeb4141cd3 +README.md: f74182e468c8c844be71f762c6f0dd266cf799f3 +README.zh.md: e55d481dc61ae52b7d08ff09ffb0595ce085fd76 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 7c3073aefb..f74182e468 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -122,6 +122,8 @@ Prompt admission uses the actual `prepareCall()` result, not the preceding `requ ### Failure and cancellation +`turn/end` declares `TurnEndCancelCause`; cancellation records a fresh `AgentCancelCause` there, retaining the caller's `kind` and the hook's `reason` text. The live `AbortSignal.reason` remains the caller's object, which a transport may extend — Node's fetch assigns a `stack` onto it — so the copy keeps that trace out of the log and keeps the ending appendable. + Final adapter selection, dispatch, and iteration failures arrive as terminal finishes and enter `agent/request-error`; a handling listener returns `{ kind: 'retry' }` without calling `next()`, while an unhandled failure is terminal. Middleware, result-processing, tool, and other extension failures remain thrown and close the turn directly — plugin failure ends the turn, not the loop. Undispatched model tool calls after cancellation receive synthetic `tool/call` plus `ABORTED_BEFORE_DISPATCH` result pairs. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the signal lifecycle. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 44621a0b02..e55d481dc6 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -122,6 +122,8 @@ const handle = await ctx.agents.create({ ### 失败与取消 +`turn/end` 声明的类型是 `TurnEndCancelCause`;取消时,循环在其中记录一份新的 `AgentCancelCause`,保留调用方的 `kind` 和 hook 的 `reason` 文本。实时 `AbortSignal.reason` 仍是调用方的那个对象,传输层可能向其添加属性——Node 的 fetch 会给它赋一个 `stack`——因此这份拷贝既让该调用栈不进入日志,也让结束事件保持可追加。 + 最终适配器选择、分发与迭代失败以终止结束的形式到达并进入 `agent/request-error`;处理该失败的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`,未被处理的失败则是终态。Middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次——插件失败结束的是轮次,不是循环。取消后未分发的模型工具调用会收到合成的 `tool/call` 加 `ABORTED_BEFORE_DISPATCH` 结果对。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)拥有信号生命周期。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4ea7593551..98114f53cd 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -23,7 +23,7 @@ import { errorChain, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' -import { deepFreeze } from '@deepseek-ai/dsh-util-values' +import { assertNever, deepFreeze } from '@deepseek-ai/dsh-util-values' import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' @@ -68,6 +68,31 @@ function requestProposal(header: EpochHeader): LlmCallConfig { return proposal } +/** + * Read the cause `cancel()` passed when aborting a loop-owned signal, copying + * only the fields `turn/end` records. The live reason stays the caller's + * object, and Node's fetch assigns a `stack` onto it that `Session.append` + * would either log or reject as data JSON cannot hold. + * @param signal - a turn or maintenance signal this loop owns. + * @returns the copied cause, or undefined while the signal is still live. + */ +function abortedCancelCause(signal: AbortSignal): AgentCancelCause | undefined { + if (!signal.aborted) return undefined + // `cancel()` is the only aborter of the signals this loop owns. + const cause = signal.reason as AgentCancelCause + switch (cause.kind) { + case 'user': + case 'parent': + case 'disposed': + return { kind: cause.kind } + case 'hook': + return { kind: 'hook', reason: cause.reason } + /* v8 ignore next -- cancel accepts the closed AgentCancelCause union */ + default: + return assertNever(cause) + } +} + /** Drives one session through turn and step boundaries. */ export class ReactLoopAgent implements Agent { readonly inbox: ReactLoopInbox @@ -170,7 +195,7 @@ export class ReactLoopAgent implements Agent { return await job(maintenance.abort.signal) } finally { this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) - const cause = maintenance.abort.signal.reason as AgentCancelCause | undefined + const cause = abortedCancelCause(maintenance.abort.signal) if (cause?.kind !== 'disposed' && maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver() done.resolve() } @@ -190,7 +215,7 @@ export class ReactLoopAgent implements Agent { // Maintenance and aborted drivers cannot deliver the wake: latch it for // replay at convergence. Live drivers claim queued work themselves; // disposal never latches, so teardown waits on no model turn. - const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined + const reason = abortedCancelCause(this.phase.abort.signal) if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { this.phase.wakeRequested = true } @@ -321,8 +346,10 @@ export class ReactLoopAgent implements Agent { target = 'next-step' } } catch (error: unknown) { - if (signal.aborted) { - turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause } + // A cause is present exactly while the signal is aborted. + const cause = abortedCancelCause(signal) + if (cause !== undefined) { + turnEnds = { kind: 'aborted', reason: cause } throw error } // Every failure is structured: an `LlmError` keeps its facts, anything diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 872e983639..add362835b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -3,7 +3,8 @@ import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek- * Tests for the queue-aware `Agent.cancel()` primitive. The default clears * queued and steering work, while `keepInbox` preserves pending input for a * later wake after the active turn reaches quiescence. The suite - * covers every landing window plus signal reset and `whenIdle()` quiescence. + * covers every landing window plus signal reset, `whenIdle()` quiescence, and + * the cause each cancelled turn records. * @module dsh-agent-loop/tests/cancel */ @@ -13,7 +14,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SessionLogOffset, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type AgentCancelCause } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -57,6 +58,92 @@ function userTexts(agent: Agent): string[] { } describe('Agent.cancel()', () => { + /** + * Cancel one turn, let `mutate` alter the caller's cause the way a transport + * does once it observes the abort, then report every recorded turn ending. + */ + async function endingsAfterMutatingCancel( + cause: AgentCancelCause, + mutate: (cause: AgentCancelCause) => void, + ): Promise<{ endings: TurnEndReason[]; errors: unknown[]; requests: number }> { + const adapter = new MockAdapter([textResponse('next turn')]) + const ctx = await harness(adapter) + try { + const agent = await ctx.agentLoop.create(SessionId('cancel-mutated-cause'), { provider: 'mock', model: 'mock' }) + const errors: unknown[] = [] + ctx.on('agent/error', ({ error }) => { errors.push(error) }) + ctx.on('agent/request', ({ signal, turn }, next) => { + if (turn === 1) { + signal.addEventListener('abort', () => { mutate(cause) }, { once: true }) + agent.cancel(cause) + } + return next() + }) + + send(agent, 'cancel this turn') + await agent.whenIdle() + // A second turn proves the cancelled one closed: an unclosed turn would + // make the next `turn/start` fail the session's turn-bracket invariant. + send(agent, 'continue') + await agent.whenIdle() + + return { + endings: agent.session.snapshotEvents() + .flatMap(event => event.type === 'turn/end' ? [event.data.reason] : []), + errors, + requests: adapter.requests.length, + } + } finally { + await ctx.fiber.dispose() + } + } + + /** + * Node's fetch assigns an enumerable `stack` string onto the abort reason it + * receives (`node:internal/deps/undici`), which a plain assignment reproduces. + */ + function assignTransportStack(cause: AgentCancelCause): void { + Object.defineProperty(cause, 'stack', { + value: 'Error\n at node:internal/deps/undici/undici', + enumerable: true, + writable: true, + configurable: true, + }) + } + + /** Add an own property JSON discards, the descriptor `Error.captureStackTrace` produces. */ + function captureNonEnumerableStack(cause: AgentCancelCause): void { + Error.captureStackTrace(cause) + } + + it.each([ + { kind: 'user' }, + { kind: 'parent' }, + { kind: 'hook', reason: 'policy stopped this turn' }, + { kind: 'disposed' }, + ])('records $kind cancellation without the stack a transport assigned to the abort reason', async (cause) => { + const expectedCause = { ...cause } + + const { endings, errors, requests } = await endingsAfterMutatingCancel(cause, assignTransportStack) + + expect(Object.getOwnPropertyDescriptor(cause, 'stack')?.enumerable).toBe(true) + expect(errors).toEqual([]) + expect(endings).toEqual([{ kind: 'aborted', reason: expectedCause }, { kind: 'completed' }]) + expect(requests).toBe(1) + }) + + it('closes a cancelled turn whose abort reason acquired an own property JSON discards', async () => { + const cause: AgentCancelCause = { kind: 'user' } + + // `Session.append` rejects a payload JSON cannot hold rather than logging a + // lossy ending, so the caller's object must not reach the log by reference. + const { endings, errors } = await endingsAfterMutatingCancel(cause, captureNonEnumerableStack) + + expect(Object.getOwnPropertyDescriptor(cause, 'stack')?.enumerable).toBe(false) + expect(errors).toEqual([]) + expect(endings).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) + }) + it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 0a5d7555f0..6935ed1d06 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -143,7 +143,6 @@ const SECTION_ORDERS = { TOOL_LSP: 2200, TOOL_SESSION_QUERY: 2300, TOOL_GOAL: 2400, - TOOL_CORDIS: 2500, TOOL_WORKFLOW: 2600, TOOL_RALPH: 2700, TOOL_SUBAGENT: 2800, diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 1d73203062..16a79c807f 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -18,7 +18,7 @@ const SECTION_ORDER_NAMES = [ 'PLAN_POLICY', 'TEAM_POLICY', 'PTC_ONLY', 'FILE_REFERENCE', 'TOOL_BASH', 'TOOL_PWSH', 'TOOL_READ', 'TOOL_WRITE', 'TOOL_EDIT', 'TOOL_GLOB', 'TOOL_GREP', 'TOOL_JOBS', 'TOOL_PTY', 'TOOL_WEB_SEARCH', 'TOOL_WEB_FETCH', - 'TOOL_LSP', 'TOOL_SESSION_QUERY', 'TOOL_GOAL', 'TOOL_CORDIS', 'TOOL_WORKFLOW', + 'TOOL_LSP', 'TOOL_SESSION_QUERY', 'TOOL_GOAL', 'TOOL_WORKFLOW', 'TOOL_RALPH', 'TOOL_SUBAGENT', 'TOOL_REPORT', 'TOOLS_SDK', 'DELIVERABLE_FILE_REFERENCES', 'STRUCTURED_OUTPUT', 'HARNESS_SOURCE', 'WEB_SURFACE', 'DEPLOYMENT_PERSONA_SUFFIX', diff --git a/packages/document/office-to-pdf/src/index.ts b/packages/document/office-to-pdf/src/index.ts index fbcff20e1a..f8d3b993c9 100644 --- a/packages/document/office-to-pdf/src/index.ts +++ b/packages/document/office-to-pdf/src/index.ts @@ -187,7 +187,7 @@ export class OfficeToPdf extends TypertRemoteService { const files = this.ctx.get('workspaceFiles') const fs = this.ctx.get('fs') if (files === undefined || fs === undefined) throw new OfficeToPdfError('unavailable', 'Office file rendering requires workspaceFiles and fs.') - const authorized = await files.readBytes(scope, path, { offset: 0, length: 1 }, signal) + const authorized = await files.readBytes(scope, path, { range: { offset: 0, length: 1 } }, signal) const source = await files.stat(scope, path, signal) const assertUnchanged = (current: WorkspaceFileStat): void => { if (current.absolutePath !== source.absolutePath || current.version !== source.version) { diff --git a/packages/document/office-to-pdf/src/types.ts b/packages/document/office-to-pdf/src/types.ts index a1123a5ba1..5138dad52c 100644 --- a/packages/document/office-to-pdf/src/types.ts +++ b/packages/document/office-to-pdf/src/types.ts @@ -45,7 +45,9 @@ export type OfficeToPdfErrorCode = | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' /** PDF content carries the original Office file's absolute path and version. */ -export interface RenderedDocumentBytes extends WorkspaceFileBytes { +export interface RenderedDocumentBytes extends Omit { + /** Base64 PDF contents carried by the JSON Remote response. */ + readonly data: string readonly missingFonts: string[] readonly generation: OfficeToPdfGeneration } diff --git a/packages/document/office-to-pdf/tests/remote.spec.ts b/packages/document/office-to-pdf/tests/remote.spec.ts index 542dc1a333..4029f3ea74 100644 --- a/packages/document/office-to-pdf/tests/remote.spec.ts +++ b/packages/document/office-to-pdf/tests/remote.spec.ts @@ -31,7 +31,7 @@ beforeEach(async () => { fileInfo = vi.fn().mockResolvedValue(sourceInfo) processPath = vi.fn().mockReturnValue(source.absolutePath) ctx.provide('fs', { resolve: vi.fn().mockResolvedValue(target), stat: fileInfo, processPath, readBytes: read } as never) - authorize = vi.fn().mockResolvedValue(wireSource) + authorize = vi.fn().mockResolvedValue({ ...wireSource, eof: false, data: rawSource.data.subarray(0, 1) }) metadata = vi.fn().mockResolvedValue(source) await ctx.plugin(OfficeToPdf) generation = ctx.officeToPdf.generation @@ -48,7 +48,7 @@ it.each(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])('converts authorized %s b const path = `report.${extension.toUpperCase()}` const result = await ctx.officeToPdf.render(scope, path, 'foreground', new AbortController().signal) expect(read).toHaveBeenCalledExactlyOnceWith(target, expect.any(AbortSignal), 4) - expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { offset: 0, length: 1 }, expect.any(AbortSignal)) + expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { range: { offset: 0, length: 1 } }, expect.any(AbortSignal)) expect(render).toHaveBeenCalledOnce() expect(render.mock.calls[0]?.[0]).toMatchObject({ extension, priority: 'foreground', source: { version: source.version, bytes: 4 } }) expect(result).toEqual({ ...wireSource, data: Buffer.from(pdf).toString('base64'), bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0591979188..af3043fd78 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -406,7 +406,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.composer.bar', @@ -445,7 +445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:207', }, { key: 'conversation.composer.dock', @@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199', }, { key: 'conversation.header', @@ -536,7 +536,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', }, { key: 'conversation.header.leading', @@ -565,7 +565,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:164', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', }, { key: 'conversation.hero.agentPreset', @@ -602,7 +602,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:193', }, { key: 'conversation.hero.brand.mark', @@ -631,7 +631,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:191', }, { key: 'conversation.hero.workspace', @@ -664,7 +664,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -735,7 +735,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', }, { key: 'conversation.input.attachments', @@ -745,7 +745,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddFiles: (files: readonly File[]) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', @@ -776,7 +776,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:209', }, { key: 'conversation.input.dock', @@ -839,7 +839,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:191', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', }, { key: 'conversation.input.left', @@ -893,7 +893,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', }, { key: 'conversation.input.model', @@ -932,7 +932,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:215', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:219', }, { key: 'conversation.input.overlay', @@ -990,7 +990,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:193', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197', }, { key: 'conversation.input.permission', @@ -1029,7 +1029,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:217', }, { key: 'conversation.input.plan', @@ -1068,7 +1068,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:211', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:215', }, { key: 'conversation.input.right', @@ -1122,7 +1122,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', }, { key: 'conversation.message.images', @@ -1260,7 +1260,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', }, { key: 'conversation.session.header', @@ -1297,7 +1297,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:141', }, { key: 'conversation.session.header.actions', @@ -1359,7 +1359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:152', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156', }, { key: 'conversation.session.header.corner', @@ -1398,7 +1398,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', }, { key: 'conversation.session.header.lineage', @@ -1439,7 +1439,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:146', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150', }, { key: 'conversation.session.header.utilities', @@ -1498,7 +1498,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:158', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162', }, { key: 'conversation.trajectory.images', @@ -1602,7 +1602,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'deliverables.file.actions', @@ -1793,7 +1793,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'plugins.bundle.activation', diff --git a/packages/extensions/tool-cordis/README.i18n.yaml b/packages/extensions/tool-cordis/README.i18n.yaml index d9eef3eede..3f3feb821d 100644 --- a/packages/extensions/tool-cordis/README.i18n.yaml +++ b/packages/extensions/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/extensions/tool-cordis/README.md -README.md: a0152cf3dbdd08599ba431113123c6aaf097723d -README.zh.md: cde328fce54b4c18f57b7a044e6afc24215417dd +README.md: 4e7efefd21e3b7cf40e8ba5fe2291d220dfd796f +README.zh.md: 76331c25f0996c053b39e5b227a50afae84e6bb4 diff --git a/packages/extensions/tool-cordis/README.md b/packages/extensions/tool-cordis/README.md index a0152cf3db..4e7efefd21 100644 --- a/packages/extensions/tool-cordis/README.md +++ b/packages/extensions/tool-cordis/README.md @@ -25,7 +25,7 @@ Inspect Host and Client runtime APIs before writing plugin code. Creator mode pr ## Use this package -Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-tool-cordis` alongside the host runner that provides `cordisInspect`. Call `cordis_inspect_list` to discover providers, then `cordis_inspect_query` for a provider's exact methods and types. Use [Plugin Manager](../../boot/plugin-manager/README.md) to install bundles containing plugin code or MCP configuration. +Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-tool-cordis/host` once in the host composition beside the host runner that provides `cordisInspect`, and `@deepseek-ai/dsh-tool-cordis` in each agent preset that exposes the tools; a preset row alone registers no Host providers. Call `cordis_inspect_list` to discover providers, then `cordis_inspect_query` for a provider's exact methods and types. Use [Plugin Manager](../../boot/plugin-manager/README.md) to install bundles containing plugin code or MCP configuration. ----- @@ -35,7 +35,7 @@ Creator mode includes this toolset. Other compositions mount `@deepseek-ai/dsh-t
Implementation internals — click to expand -Host providers combine generated Service/Event catalogs and the requesting agent's tool registry. Client providers synchronize their manifests through the existing inspection registry and answer queries from a connected page. The tool plugin owns its registrations through Cordis effects; disposal removes both tools and prompt contributions. No invariant companion is published because inspection reads its providers directly and maintains no independent runtime projection. +Host providers combine generated Service/Event catalogs and the requesting agent's tool registry. Client providers synchronize their manifests through the existing inspection registry and answer queries from a connected page. The host entry owns the Host provider registrations and the preset row owns the two tools, each through Cordis effects; the registry rejects a duplicate provider id, which is why the providers register once per process rather than per preset. No invariant companion is published because inspection reads its providers directly and maintains no independent runtime projection.
@@ -54,15 +54,15 @@ Host providers combine generated Service/Event catalogs and the requesting agent #### What the model sees -The [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) describes two read-only inspection tools. The [prompt](src/prompt.ts) directs persistent changes through Plugin Manager and describes MCP setup. Creator visual requests default to an installed UI plugin displayed in the current Web page; the development skill covers Client packaging and slot registration. Query results contain the requested API declarations or live tool schemas. +The [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) describes two read-only inspection tools. The plugin contributes no system prompt section: the tool descriptions state when to call each tool and that queries never invoke business methods. In the `cordis` preset, the first-turn skill catalog carries the descriptions of the two shipped skills, which route plugin, MCP, composition, and destination-less visual requests to the skill covering Plugin Manager, MCP setup, Client packaging, and slot registration. Query results contain the requested API declarations or live tool schemas. #### Token effect -Both tool schemas and the guidance section enter model requests while this plugin is visible. Query results append to the transcript; exact queries avoid loading unrelated declarations. +Only the two tool schemas enter model requests while this plugin is visible. Query results append to the transcript; exact queries avoid loading unrelated declarations. #### KV Cache effect -Unchanged schemas and guidance remain prefix-stable. Query results append to history; enabling other plugins can change subsequent tool schemas. +Unchanged tool schemas remain prefix-stable. Query results append to history; enabling other plugins can change subsequent tool schemas. ## Known Limitations and Deferred Work diff --git a/packages/extensions/tool-cordis/README.zh.md b/packages/extensions/tool-cordis/README.zh.md index cde328fce5..76331c25f0 100644 --- a/packages/extensions/tool-cordis/README.zh.md +++ b/packages/extensions/tool-cordis/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -创造模式包含这组工具。其他组合需要同时挂载 `@deepseek-ai/dsh-tool-cordis` 和提供 `cordisInspect` 的 host runner。调用 `cordis_inspect_list` 发现 provider,再用 `cordis_inspect_query` 查询其具体方法和类型。通过 [Plugin Manager](../../boot/plugin-manager/README.zh.md) 安装包含插件代码或 MCP 配置的组合包。 +创造模式包含这组工具。其他组合需要在宿主组合里、提供 `cordisInspect` 的 host runner 旁挂载一次 `@deepseek-ai/dsh-tool-cordis/host`,并在每个要暴露这些工具的 agent preset 里挂载 `@deepseek-ai/dsh-tool-cordis`;仅有 preset 行不会注册任何 Host provider。调用 `cordis_inspect_list` 发现 provider,再用 `cordis_inspect_query` 查询其具体方法和类型。通过 [Plugin Manager](../../boot/plugin-manager/README.zh.md) 安装包含插件代码或 MCP 配置的组合包。 ----- @@ -35,7 +35,7 @@ kind: "package-reference"
实现细节 — 点击展开 -Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注册表。Client provider 通过现有检查注册表同步清单,并从已连接页面回答查询。工具插件通过 Cordis effect 持有注册;释放时移除工具和提示词贡献。检查直接读取 provider,不维护独立运行时投影,因此不发布不变式配套插件。 +Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注册表。Client provider 通过现有检查注册表同步清单,并从已连接页面回答查询。宿主入口持有 Host provider 的注册,preset 行持有两个工具,都通过 Cordis effect;注册表拒绝重复的 provider id,所以 provider 按进程注册一次而不是按 preset 注册。检查直接读取 provider,不维护独立运行时投影,因此不发布不变式配套插件。
@@ -54,15 +54,15 @@ Host provider 结合生成的 Service/Event 目录与请求 agent 的工具注 #### 模型所见 -[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-cordis) 描述两个只读检查工具。[提示词](src/prompt.ts) 指引模型通过 Plugin Manager 进行持久化变更并说明 MCP 设置方式。创造模式的视觉请求默认通过已安装的 UI 插件显示在当前 Web 页面;开发技能说明 Client 打包和 slot 注册方法。查询结果包含所请求的 API 声明或当前工具 schema。 +[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-cordis) 描述两个只读检查工具。插件不贡献 system prompt 段落:工具描述已说明何时调用每个工具以及查询不会调用业务方法。在 `cordis` preset 中,首轮 skill catalog 携带两个随附技能的描述,把插件、MCP、组合编辑和未指定去向的视觉请求路由到覆盖 Plugin Manager、MCP 设置、Client 打包和 slot 注册的技能。查询结果包含所请求的 API 声明或当前工具 schema。 #### Token 影响 -插件可见时,两个工具 schema 和指导段落进入模型请求。查询结果追加到转录中;精确查询避免加载无关声明。 +插件可见时,只有两个工具 schema 进入模型请求。查询结果追加到转录中;精确查询避免加载无关声明。 #### KV Cache 影响 -未改变的 schema 和指导保持前缀稳定。查询结果追加到历史中;启用其他插件可能改变后续工具 schema。 +未改变的工具 schema 保持前缀稳定。查询结果追加到历史中;启用其他插件可能改变后续工具 schema。 ## 已知限制与待办 diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 8e9d109d8b..b844de74e3 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -18,18 +18,22 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./host": { + "types": "./lib/types/host.d.ts", + "default": "./lib/types/host.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 06602809cb..ddf1e301c6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -987,7 +987,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.', methods: [ { - signature: 'abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise>', + signature: 'watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise>', description: 'Observe one file or a directory\'s direct entries in this provider\'s execution world.', parameters: [{ name: 'target', description: 'resolved file or directory, including an absent path to observe for creation.' }, { name: 'changed', description: 'invalidation callback; errors can be reported during or after initialization.' }, { name: 'signal', description: 'cancels watcher initialization; the caller closes an initialized watcher.' }], returns: 'a promise resolving once observation is active, with an asynchronous close function.', @@ -3365,22 +3365,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the page, the file\'s version at the stat before it, and whether it reaches the last line.', }, { - signature: '@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise', - description: 'Read one byte window of a regular file readable by the filesystem backend: raw bytes, no text decoding and no binary rejection.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'range', description: 'the byte window; omitted fields take the window defaults.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'the window in base64, the file\'s version and size at the stat before it, and whether it reaches the last byte.', - }, - { - signature: '@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise', - description: 'Read a complete regular file as bytes, subject to the configured full-file cap.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', - }, - { - signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise', - description: 'Read a complete file relative to another file\'s directory, including outside the workspace.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'base file, absolute or workspace-relative.' }, { name: 'relativePath', description: 'relative filesystem path, not a URL or absolute path.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'the complete related file using the ordinary file-size and access checks.', + signature: '@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise', + description: 'Read a complete regular file or one byte range without text decoding.', + parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'target path, absolute or workspace-relative; relative to the base file\'s directory when provided.' }, { name: 'options', description: 'optional base file and range; without a range the complete-file cap applies.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'native bytes with the file\'s version and size at the preceding stat, byte offset, and EOF marker.', }, { signature: '@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise', @@ -4452,6 +4440,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ConnectionRequestRejection', declaration: 'export type ConnectionRequestRejection = 401 | 403 | undefined;', }, + { + name: 'ConnectionRpcAttachment', + declaration: 'export interface ConnectionRpcAttachment {\n readonly path: readonly (string | number)[];\n readonly bytes: Uint8Array;\n}', + }, { name: 'ConnectionRpcEndpointMatcher', declaration: 'export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean;', @@ -4462,7 +4454,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionRpcHandler', - declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise>;', + declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise;', + }, + { + name: 'ConnectionRpcHandlerResult', + declaration: 'export type ConnectionRpcHandlerResult = {\n readonly ok: true;\n readonly value: unknown;\n readonly attachments?: readonly ConnectionRpcAttachment[];\n} | {\n readonly ok: false;\n readonly error: ConnectionRpcFailure;\n};', }, { name: 'ConnectionRpcResult', @@ -5702,7 +5698,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RenderedDocumentBytes', - declaration: 'export interface RenderedDocumentBytes extends WorkspaceFileBytes {\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', + declaration: 'export interface RenderedDocumentBytes extends Omit {\n readonly data: string;\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', }, { name: 'ReplayEnvelope', @@ -7034,7 +7030,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} | {\n readonly mode: \'src-json\';\n};', + 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};', }, { name: 'TypertContribution', @@ -7332,6 +7328,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceByteRange', declaration: 'export interface WorkspaceByteRange {\n readonly offset?: number;\n readonly length?: number;\n}', }, + { + name: 'WorkspaceByteReadOptions', + declaration: 'export interface WorkspaceByteReadOptions {\n readonly range?: WorkspaceByteRange;\n readonly baseFile?: string;\n}', + }, { name: 'WorkspaceChangedFile', declaration: 'export interface WorkspaceChangedFile {\n path: string;\n display: string;\n added: number;\n deleted: number;\n binary?: true;\n oversized?: true;\n}', @@ -7370,7 +7370,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkspaceFileBytes', - declaration: 'export interface WorkspaceFileBytes extends WorkspaceFileStat {\n readonly offset: number;\n readonly data: string;\n readonly eof: boolean;\n}', + declaration: 'export interface WorkspaceFileBytes extends WorkspaceFileStat {\n readonly offset: number;\n readonly data: Data;\n readonly eof: boolean;\n}', }, { name: 'WorkspaceFileChange', diff --git a/packages/extensions/tool-cordis/src/host.ts b/packages/extensions/tool-cordis/src/host.ts new file mode 100644 index 0000000000..d3c024f77e --- /dev/null +++ b/packages/extensions/tool-cordis/src/host.ts @@ -0,0 +1,20 @@ +/** Host-plane registration of the first-party Cordis inspect providers. */ +import type { Context } from '@deepseek-ai/cordis' +import { hostInspectProviders } from './providers.ts' + +export const name = 'cordis-inspect-providers' +/** Required services: the Host inspect registry and the Tool registry the `Tool` provider reads. */ +export const inject = ['cordisInspect', 'tools'] + +/** + * Register the Host inspect providers once per process. The registry keys + * providers by id and rejects a duplicate, so this row belongs to the host + * composition beside `cordis-host-runner`; every per-session `tool-cordis` + * row reads the shared set through its tools. + * @param ctx Host composition context. + */ +export function apply(ctx: Context): void { + for (const provider of hostInspectProviders(ctx)) { + ctx.effect(() => ctx.cordisInspect.register(provider), `cordis-inspect-providers: ${provider.manifest.id}`) + } +} diff --git a/packages/extensions/tool-cordis/src/index.ts b/packages/extensions/tool-cordis/src/index.ts index 90f3401d75..d52ed7a076 100644 --- a/packages/extensions/tool-cordis/src/index.ts +++ b/packages/extensions/tool-cordis/src/index.ts @@ -5,25 +5,20 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { presentInspectListCall, presentInspectQueryCall } from './present.ts' -import { CORDIS_SYSTEM_PROMPT } from './prompt.ts' -import { hostInspectProviders } from './providers.ts' export const name = 'tool-cordis' -export const inject = ['tools', 'systemPrompt', 'cordisInspect'] +export const inject = ['tools', 'cordisInspect'] function requireAgent(exec: ToolExecution): Agent { if (exec.agent === undefined) throw new Error('Cordis inspection requires an Agent-backed session') return exec.agent } -/** Register read-only runtime inspection tools. +/** Register read-only runtime inspection tools over the Host providers that + * `@deepseek-ai/dsh-tool-cordis/host` registers once per process. * @param ctx Agent-scoped registration context. */ export function apply(ctx: Context): void { - ctx.systemPrompt.section({ name: 'tool:cordis', order: ctx.systemPrompt.getSectionOrder('TOOL_CORDIS'), text: CORDIS_SYSTEM_PROMPT }) - for (const provider of hostInspectProviders(ctx)) { - ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`) - } ctx.tools.register(defineTool({ name: 'cordis_inspect_list', description: @@ -46,16 +41,12 @@ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ name: 'cordis_inspect_query', description: - 'Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come ' - + 'from cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before writing plugin code ' + 'Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from ' + + 'cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before writing plugin code ' + 'to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot ' + 'trees and props. Host queries run locally. A Client query waits for the first valid page response and ' + 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service ' - + 'methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate ' - + 'the compact signature directory, then query the exact service or event for its structured contract and ' - + 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an ' - + 'exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, ' - + 'scope, and registrant.', + + 'methods or modify the runtime.', parameters: { platform: { type: 'string', required: true, enum: ['host', 'client'], description: 'Runtime platform that owns the Provider.' }, provider: { type: 'string', required: true, description: 'Exact Provider ID returned by cordis_inspect_list.' }, diff --git a/packages/extensions/tool-cordis/src/prompt.ts b/packages/extensions/tool-cordis/src/prompt.ts deleted file mode 100644 index 6f0dd7eafb..0000000000 --- a/packages/extensions/tool-cordis/src/prompt.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Model guidance for read-only runtime API discovery. */ -export const CORDIS_SYSTEM_PROMPT = `# Harness runtime inspection - -Use cordis_inspect_list to discover Host and Client providers, then cordis_inspect_query to read exact Service, Event, Tool, Theme or Slot APIs. These tools are read-only; queries do not invoke business methods.` diff --git a/packages/extensions/tool-cordis/tests/host.spec.ts b/packages/extensions/tool-cordis/tests/host.spec.ts new file mode 100644 index 0000000000..8763e44c44 --- /dev/null +++ b/packages/extensions/tool-cordis/tests/host.spec.ts @@ -0,0 +1,35 @@ +import { Context } from '@deepseek-ai/cordis' +import { CordisInspectRegistryService } from '@deepseek-ai/dsh-cordis-host-runner' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { describe, expect, it } from 'vitest' +import * as CordisInspectProviders from '../src/host.ts' + +/** + * The Host inspect providers are one process-global set: the host entry + * registers them beside the registry, and the per-session tool rows only read + * them. Mounting several presets that carry `tool-cordis` over the shared + * registry is covered by `apps/cli/tests/web-agent-presets.e2e.ts`. + */ + +async function host(): Promise { + const ctx = new Context() + // The tool registry injects `systemPrompt`; nothing under test registers a prompt section. + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(CordisInspectRegistryService) + return ctx +} + +describe('the cordis-inspect-providers host entry', () => { + it('registers the first-party Host providers and withdraws them on disposal', async () => { + const ctx = await host() + const fiber = ctx.plugin(CordisInspectProviders) + await fiber + + expect(ctx.cordisInspect.list().map(provider => provider.id)).toEqual(['Service', 'Event', 'Builtin', 'Tool']) + + await fiber.dispose() + expect(ctx.cordisInspect.list()).toEqual([]) + }) +}) diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 6f4ebe4e50..1158a6dede 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -10,6 +10,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { FsError } from './types.ts' import type { FsDirEntry, FsEditOutcome, @@ -96,7 +97,12 @@ export abstract class FileSystem extends Service { * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ - abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> + watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> { + void target + void changed + signal.throwIfAborted() + return Promise.reject(new FsError('Filesystem watching is not supported by this provider.', 'FS_IO_ERROR')) + } /** * The sandbox mode this backend enforces on mutations BY DEFAULT, or diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 41e698a70e..e3105925a5 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -21,7 +21,6 @@ import type { /** A minimal in-memory fake implementing the provider primitives. */ class FakeFileSystem extends FileSystem { - override watch(): never { throw new Error('Fixture does not support watching') } files = new Map() override async resolve(path: string): Promise { @@ -145,6 +144,14 @@ describe('FileSystem provider seam', () => { await expect(fs.readBytes(target, undefined, 1)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) }) + it('rejects watching when a provider does not implement observation', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + await expect(fs.watch(await fs.resolve('a.txt'), () => {}, new AbortController().signal)) + .rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) + it('listDir returns child entry targets without reading file content', async () => { const ctx = new Context() await ctx.plugin(FakeFileSystem) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 96a6f4432f..3f353ad6d9 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: df1881a3a16b69f61127956127c7806c70793f91 -README.zh.md: 1d07d43ac97c616c9b7450da71ad09cb55fa4267 +README.md: 9dd698404719eecdf9fb4c40720ccc74ab37be9d +README.zh.md: d4701b79e432dfa3e8ba8485f8644758ec3b34d7 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index df1881a3a1..9dd6984047 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -38,7 +38,7 @@ Compose the webserver as the HTTP transport of a browser-facing host, then let t `host` accepts exactly two values: `127.0.0.1` (default posture, loopback only) and `0.0.0.0` (deliberate network exposure — the server carries no TLS, authentication, or origin policy of its own). `port` 0 requests an OS-assigned port; `ctx.webServer.port` reads the listening port afterwards. -Set `compression: 'gzip'` to wrap eligible socket-backed responses without changing route APIs. The client must accept gzip and the media type must be compressible; known response lengths below `compressionThresholdBytes` remain uncompressed, while unknown-length streams are eligible immediately. Existing encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image remain unchanged. The shipped Web bundle uses compression level 1 with a 1024-byte threshold; other compositions default to no compression. +Set `compression: 'gzip'` to wrap eligible socket-backed responses without changing route APIs. The client must accept gzip and the media type must be compressible or `multipart/form-data`; known response lengths below `compressionThresholdBytes` remain uncompressed, while unknown-length streams are eligible immediately. Existing encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image remain unchanged. The shipped Web bundle uses compression level 1 with a 1024-byte threshold; other compositions default to no compression. ### Registering routes diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 1d07d43ac9..d4701b79e4 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" `host` 只接受两个值:`127.0.0.1`(默认姿态,仅回环)与 `0.0.0.0`(有意向网络开放——服务器自身不携带 TLS、认证或来源策略)。`port` 为 0 时请求 OS 分配端口;之后用 `ctx.webServer.port` 读取正在监听的端口。 -设置 `compression: 'gzip'` 可以包装符合条件的 socket-backed 响应,而不改变 route API。客户端必须接受 gzip,且媒体类型必须可压缩;已知长度小于 `compressionThresholdBytes` 的响应保持未压缩,未知长度的流则立即符合条件。已有编码、`Cache-Control: no-transform`、range 响应、SSE(Server-Sent Events)、ZIP 与已打包的 `.gz` Worker image 均保持不变。随附 Web bundle 使用 level 1 与 1024 字节阈值;其他组合默认不压缩。 +设置 `compression: 'gzip'` 可以包装符合条件的 socket-backed 响应,而不改变 route API。客户端必须接受 gzip,且媒体类型必须可压缩或为 `multipart/form-data`;已知长度小于 `compressionThresholdBytes` 的响应保持未压缩,未知长度的流则立即符合条件。已有编码、`Cache-Control: no-transform`、range 响应、SSE(Server-Sent Events)、ZIP 与已打包的 `.gz` Worker image 均保持不变。随附 Web bundle 使用 level 1 与 1024 字节阈值;其他组合默认不压缩。 ### 注册路由 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 7443d04211..5b490fc770 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -95,6 +95,7 @@ function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware { if (response.getHeader('content-range') !== undefined) return false const contentType = response.getHeader('content-type') if (typeof contentType === 'string' && contentType.toLowerCase().startsWith('text/event-stream')) return false + if (typeof contentType === 'string' && /^multipart\/form-data(?:;|$)/i.test(contentType)) return true return compressionMiddleware.filter(request, response) }, }) as NodeMiddleware diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 6322a88f27..8fd91ece9e 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -14,19 +14,26 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import * as Retry from '../src/index.ts' let context: Context | undefined -const servers: MockLlmServer[] = [] +const servers: Promise[] = [] afterEach(async () => { - await context?.fiber.dispose() + const ownedContext = context context = undefined - await Promise.all(servers.splice(0).map(server => server.close())) + // A failed transport can still await a stalled socket while its context disposes. + const results = await Promise.allSettled([ + ownedContext?.fiber.dispose(), + ...servers.splice(0).map(async server => (await server).close()), + ]) + vi.unstubAllEnvs() + const failure = results.find(result => result.status === 'rejected') + if (failure?.status === 'rejected') throw failure.reason }) -async function start( +function start( sequence: readonly MockLlmBehavior[], options: Omit[0], 'sequence'> = {}, ): Promise { - const server = await startMockLlmServer({ sequence, ...options }) + const server = startMockLlmServer({ sequence, ...options }) servers.push(server) return server } @@ -218,7 +225,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') - }, 10_000) + }) it('stops after the configured transport retry budget is exhausted', async () => { const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], { diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml index 9df711310c..c0a5ab802a 100644 --- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml @@ -5,37 +5,21 @@ # # It exists so a person can ask an agent to author another agent. Everything in # `standard` is here unchanged; Creator adds persistent plugin management, -# runtime inspection, composition-authoring skills, and a persona that explains -# where profile and preset changes belong. +# runtime inspection, and composition-authoring skills. The persona is the +# `standard` one: tool descriptions and the skill catalog carry the operating +# detail, and the skills own the off-limits rule (an upgrade overwrites the +# shipped preset install, and corrupting the `cordis` preset disables this mode). # # Profile management persists configuration shared by every session in that profile. -# The preset's own persona, shadowing the deployment default for this agent. +# Same persona as `standard`, shadowing the deployment default for this agent. # `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. - id: persona name: '@deepseek-ai/dsh-persona' config: suffix: Your working directory is {{cwd}}. - prefix: |- - You are a coding agent powered by the {{model}} model, running on the DeepSeek Harness. - - You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session. - - Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it. - - Presets you author live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`. Load `editing-cordis-compositions` for file discovery and copying instructions. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. - - Load the `editing-cordis-compositions` skill before writing or changing a composition. - - Use plugin_manager for persistent bundle installation, removal and enablement in the current profile. Author plugin code and configuration as ordinary workspace bundle files. Changes affect every session in that profile. Read saved-state and activation outcomes separately; restart-required means the new capability is not available yet. - - In Creator mode, requests to make a visual object, decoration, or widget mean creating an installed UI plugin that displays it in this Harness Web UI, unless the user specifies another destination. Choose reasonable visual details and proceed. Load cordis-plugin-development, inspect the Client slots, build the plugin in the workspace, and install it with plugin_manager. Install a minimal working version before visual refinement; use the connected page as its preview. Verify that the open page renders it; a standalone image or HTML file does not complete an in-app creation request. - - Load the cordis-plugin-development skill before authoring an installed plugin. Use cordis_inspect_list to discover Host and Client providers, then cordis_inspect_query to read exact Service, Event, Tool, Theme or Slot APIs. These tools are read-only; queries do not invoke business methods. - - To connect an MCP server, create a configuration-only bundle whose patch inserts @deepseek-ai/dsh-mcp-client, then install it with plugin_manager. Load cordis-plugin-development for the package and YAML examples. After successful activation, call one of the newly available mcp____ tools to verify the connection. - - Use installed bundles for new plugin code. Package installation may require explicit user approval for build scripts. Preserve returned failures and pending states; only report success after observing the requested capability. + prefix: >- + You are a coding agent powered by the {{model}} model. - id: agent-instructions name: '@deepseek-ai/dsh-agent-instructions' @@ -279,5 +263,8 @@ - id: present name: '@deepseek-ai/dsh-tool-present' +# Enabled exactly when the host `plugin-manager` service is (`dsh-base` gates +# it on a launched profile), so a profile-less embedding still mounts the preset. - id: tool-plugin-manager name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: !!js "!ctx.get('profileContext')" diff --git a/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md index 84a48f2a75..67aadc1cd7 100644 --- a/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md +++ b/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md @@ -1,6 +1,6 @@ --- name: cordis-plugin-development -description: Use when authoring, installing, configuring, or debugging persistent plugins and MCP connections in the current Harness profile. +description: Use when authoring, installing, configuring, or debugging persistent plugins and MCP connections in the current Harness profile, and for any visual object, decoration, or widget request that names no other destination, which means an installed UI plugin rendered in the Harness Web UI. --- # Persistent Harness plugins @@ -9,7 +9,7 @@ Use ordinary workspace files to author a bundle, then `plugin_manager install_bu ## Deliver a working plugin first -1. Resolve the requested result and destination. In Creator mode, an unspecified visual destination is the current Harness Web UI. Choose reasonable visual details and implement a small first version. +1. Resolve the requested result and destination. An unspecified visual destination is the current Harness Web UI; a standalone image or HTML file does not complete such a request. Choose reasonable visual details and implement a small first version; install it before visual refinement. 2. Discover only the APIs needed for that version: `cordis_inspect_list`, then targeted `cordis_inspect_query` calls. For UI, query Client `Slots.listSubTree` and the selected slot's registration options and props. Treat the recipes below and returned API declarations as the supported implementation path. Once the chosen slot and registration API are known, write the plugin. Before the first installation, resolve missing declarations through inspection; do not re-check these recipes by reading Loader, manifest-parser, package-manager, React, or slot implementation source. Source-level diagnosis starts from a concrete installation or runtime failure. 3. The first files you write are the installable package, patch, and required Host/Client files in one workspace directory. Check JavaScript syntax and the manifest, then install it. Before that first installation, do not create preview HTML, mock shells, design variants, screenshot scripts, or rasterizer tooling. Use the installed plugin itself as the first preview. 4. Read the installation result. After `application: applied`, exercise the capability or inspect the live Client registration. Use the connected page for visual verification when browser control is available. State any verification limitation explicitly; installation and slot registration alone do not establish what the user can see. @@ -47,7 +47,7 @@ For a simple drawing, prefer a slot with allocated space, such as `conversation. name: '@local/my-decoration' ``` -Call `plugin_manager` with `action: install_bundle` and the absolute package directory as `target`. It performs package installation and bundle selection; do not reproduce those steps with shell commands. Only pass `approvedBuilds` after the user explicitly approves the reported pending build scripts. +Call `plugin_manager` with `action: install_bundle` and the absolute package directory as `target`. It performs package installation and bundle selection; do not reproduce those steps with shell commands. Only pass `approvedBuilds` after the user explicitly approves the reported pending build scripts. Preserve returned failures and pending states; report success only after observing the requested capability. Use `list_plugins` or `list_bundles` to obtain exact identifiers for existing installations. `set_plugin` and `set_bundle` toggle them; `remove_bundle` removes a bundle. Inspect saved-state and activation outcomes separately: `failed` requires diagnosis, `overridden` means a higher-priority layer wins, and `restart-required` means the change is not live. Installing a new bundle can activate through HMR; replacing an installed package requires restart to load a fresh JavaScript module generation. Do not infer updated browser code from an unchanged slot id. @@ -105,4 +105,4 @@ Create a configuration-only bundle: its manifest needs a unique name, version, a failOnStartupError: true ``` -Replace the endpoint, install the bundle through `plugin_manager`, then call `mcp__demo__ping` or another discovered tool. For stdio, use `transport: stdio`, `command`, and optional `args`, `env`, and `cwd`. Ambient credentials are scrubbed; reference existing credentials with Loader `!!js` rather than copying secrets into conversation text. Repair the same bundle on failure instead of creating duplicates. +Replace the endpoint, install the bundle through `plugin_manager`, then call `mcp__demo__ping` or another newly available `mcp____` tool to verify the connection. For stdio, use `transport: stdio`, `command`, and optional `args`, `env`, and `cwd`. Ambient credentials are scrubbed; reference existing credentials with Loader `!!js` rather than copying secrets into conversation text. Repair the same bundle on failure instead of creating duplicates. diff --git a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md index 727099f6fc..dfdc4de73a 100644 --- a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -1,6 +1,6 @@ --- name: editing-cordis-compositions -description: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing. +description: Use when creating, changing, or validating an agent preset or other Cordis composition for this harness, including deciding host versus preset placement and checking that an authored preset mounts. --- # Editing Cordis compositions diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index 8ae0d8a261..1882d0d5b7 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { evaluate, isJsExpr } from '@deepseek-ai/cordis-plugin-loader' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -153,6 +154,19 @@ describe('the shipped preset root', () => { } }) + it('enables the cordis plugin-manager tool exactly when a launched profile provides profileContext', async () => { + // Same condition as the host `plugin-manager` row in `dsh-base`: the tool + // injects `pluginManager`, so without that service the row must be + // disabled rather than left waiting and blocking the whole mount. + const disabled = findEntry(await shippedEntries('cordis'), 'tool-plugin-manager')?.disabled + if (!isJsExpr(disabled)) throw new TypeError('cordis tool-plugin-manager must gate on a !!js expression') + const scope = (profileContext: object | undefined) => ({ + ctx: { get: (name: string) => (name === 'profileContext' ? profileContext : undefined) }, + }) + expect(evaluate(scope(undefined), disabled.__jsExpr)).toBe(true) + expect(evaluate(scope({ name: 'web' }), disabled.__jsExpr)).toBe(false) + }) + it('disables the ralph tool in every shipped preset that carries it', async () => { for (const id of ['cordis', 'ptc', 'standard']) { expect(findEntry(await shippedEntries(id), 'tool-ralph')?.disabled, id).toBe(true) diff --git a/packages/ssh/fs-ssh/README.i18n.yaml b/packages/ssh/fs-ssh/README.i18n.yaml index 9c53eeb146..33b115144a 100644 --- a/packages/ssh/fs-ssh/README.i18n.yaml +++ b/packages/ssh/fs-ssh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ssh/fs-ssh/README.md -README.md: a950726df972952a8430ef0d00ba455f2e0de11e -README.zh.md: fa69aaee52c705b094de1b0f4860109023c2069f +README.md: fe777f672ab7cf474adf10b05f6d6f646864548f +README.zh.md: 9aa92d99584c48d9179e26ccd1f733d2bda36441 diff --git a/packages/ssh/fs-ssh/README.md b/packages/ssh/fs-ssh/README.md index a950726df9..fe777f672a 100644 --- a/packages/ssh/fs-ssh/README.md +++ b/packages/ssh/fs-ssh/README.md @@ -67,7 +67,7 @@ This provider contributes no request-prefix content. Its consumers own model-vis - Whole-text reads and individual byte windows are limited to 8 MiB by the helper. Use text streaming or multiple byte windows for larger reads; other JSON transfers also obey the connection’s frame cap. -- Filesystem watching is unsupported: `watch()` throws without polling or opening a local watcher for a remote path. Ordinary reads and consumer-owned manual refresh remain available. +- Filesystem watching is unsupported: the provider keeps no `watch()` override, so the base `FS_IO_ERROR` rejection applies without polling or opening a local watcher for a remote path. Ordinary reads and consumer-owned manual refresh remain available. - Remote file URLs are execution coordinates, not host filesystem handles or Web download links. diff --git a/packages/ssh/fs-ssh/README.zh.md b/packages/ssh/fs-ssh/README.zh.md index fa69aaee52..9aa92d9958 100644 --- a/packages/ssh/fs-ssh/README.zh.md +++ b/packages/ssh/fs-ssh/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" - 辅助程序将整段文本读取及单次字节窗口限制为 8 MiB。更大的读取需使用文本流或多个字节窗口;其他 JSON 传输也受连接消息大小上限约束。 -- 不支持文件系统监听:`watch()` 直接抛错,不使用轮询,也不为远程路径建立本地监听。普通读取与消费方提供的手动刷新仍可用。 +- 不支持文件系统监听:提供方不覆写 `watch()`,由基类以 `FS_IO_ERROR` 拒绝;不使用轮询,也不为远程路径建立本地监听。普通读取与消费方提供的手动刷新仍可用。 - 远端文件 URL 是执行坐标,不是主机文件系统句柄或 Web 下载链接。 diff --git a/packages/ssh/fs-ssh/src/index.ts b/packages/ssh/fs-ssh/src/index.ts index 1bbcfbf097..9a20414a95 100644 --- a/packages/ssh/fs-ssh/src/index.ts +++ b/packages/ssh/fs-ssh/src/index.ts @@ -20,8 +20,6 @@ const errorCodes: Record = { export class SshFileSystem extends FileSystem { static inject = ['ssh', 'sandboxPolicy'] - override watch(): never { throw new Error('SSH filesystem watching is not supported') } - override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode } override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { diff --git a/packages/ssh/fs-ssh/tests/provider.spec.ts b/packages/ssh/fs-ssh/tests/provider.spec.ts index d242d4184e..b6e6626abf 100644 --- a/packages/ssh/fs-ssh/tests/provider.spec.ts +++ b/packages/ssh/fs-ssh/tests/provider.spec.ts @@ -33,8 +33,8 @@ async function setup() { describe('SSH filesystem provider', () => { it('declares watching unsupported without sending a remote request', async () => { const { fs, dispatch } = await setup() - expect(() => fs.watch(target, vi.fn(), new AbortController().signal)) - .toThrow('SSH filesystem watching is not supported') + await expect(fs.watch(target, vi.fn(), new AbortController().signal)) + .rejects.toMatchObject({ code: 'FS_IO_ERROR' }) expect(dispatch).not.toHaveBeenCalled() }) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 7ef28c6929..962421ce9c 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -233,9 +233,10 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => const child = /CHILD=(\d+)/.exec(output)?.[1] expect(child).toBeDefined() const pid = Number(child) - expect(() => process.kill(pid, 0)).not.toThrow() + expect(processIsRunning(pid)).toBe(true) await ctx.terminals.kill(agent, created.sessionId) - expect(() => process.kill(pid, 0)).toThrow() + // Linux can retain a stopped descendant as a zombie until its parent reaps it. + expect(processIsRunning(pid)).toBe(false) }, 10_000) it('quiesces a disowned same-session descendant after the shell exits naturally', async () => { diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index c5841ecac6..7b0c085cd5 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/generator/README.md -README.md: aa7fd3d6e068ea6532d55de04970ab3e28b74285 -README.zh.md: e911feac821deda1d0876c26c575dcff5cd2a741 +README.md: 9c207574e9bb576a965ab1a91576a3b5a9db397e +README.zh.md: 9a6fa9e81d7950efb4d4b76df6921bc5a83b6adf diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index aa7fd3d6e0..9c207574e9 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -85,6 +85,8 @@ Host and Client are independent TypeScript programs. Direct project references e `FaceModelEmitter` emits executable JavaScript containing success-cached Zod schema factories and the `TYPERT` contribution, plus a declaration file whose factories return `z.ZodType` through the package's public export; unsupported Zod projections fail. The Host face with Remote methods additionally emits `typert.remote-client.*` projections of Host Remote contracts for the Client. `WorkspaceTypertGenerator` validates each contributor's `package.json`: `./typert` and `./client/typert` (and `./remote` when Remote methods exist) must point at the exact generated files, and the `files` list must include them. +[Unary binary results](../protocol/README.md) are recognized after generic type resolution. Their codecs validate the byte-array type and JSON metadata without iterating, copying, or freezing the byte payload; generated Client declarations retain the authored metadata and narrow the byte backing to `ArrayBuffer`. + ### Catalog projection The runtime type closure indexes exported workspace declarations and referenced framework enums. Vendored declarations stay outside business API discovery; their enums remain available when a public result refers to them. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index e911feac82..9a6fa9e81d 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -85,6 +85,8 @@ Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定 `FaceModelEmitter` 输出包含只缓存成功结果的 Zod schema factory 与 `TYPERT` 贡献的可执行 JavaScript,以及把 factory 通过包的公开导出标注为返回 `z.ZodType` 的声明文件;不支持的 Zod 投影会失败。含 Remote 方法的 Host face 还会额外为 Client 生成 Host Remote 约定的 `typert.remote-client.*` 投影。`WorkspaceTypertGenerator` 校验每个贡献方的 `package.json`:`./typert` 与 `./client/typert`(存在 Remote 方法时还有 `./remote`)必须指向精确的生成文件,且 `files` 清单必须包含它们。 +[一元二进制结果](../protocol/README.zh.md)在泛型类型解析后被识别。其 codec 校验字节数组类型与 JSON 元数据,不遍历、复制或冻结字节载荷;生成的 Client 声明保留原有元数据类型,并将字节的底层缓冲区收窄为 `ArrayBuffer`。 + ### 目录投影 运行时类型闭包索引导出的 workspace 声明和被引用的框架 enum。Vendor 声明不参与业务 API 发现;公共结果引用其中 enum 时,目录仍提供其定义。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 7388d6c6b1..726828f0ed 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1226,6 +1226,8 @@ class FaceAnalyzer { `${registration.name}#${binding.namespace}/${exportedMethod}:result`, false, 'undefined-or-void', + false, + mode === undefined, ), location: this.location(method.name), } @@ -1511,6 +1513,7 @@ class FaceAnalyzer { requireNamed: boolean, topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject', optional = false, + allowBytes = false, ): RemoteBoundaryModel { const type = this.convertType(authoredType) const declaredType = this.checker.getTypeFromTypeNode(authoredType) @@ -1519,7 +1522,7 @@ class FaceAnalyzer { const resolvedType = optional ? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined) : declaredType - const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence) + const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence, allowBytes) const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType) const rootSymbol = this.namedWorkspaceType(authoredType) const imports = new Map() @@ -1575,6 +1578,7 @@ class FaceAnalyzer { authoredType: ts.TypeNode, resolvedType: ts.Type, topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void', + allowBytes: boolean, ): TypeNodeId { this.assertRemoteJsonType( resolvedType, @@ -1582,6 +1586,7 @@ class FaceAnalyzer { new Set(), topLevelAbsence !== 'reject', topLevelAbsence === 'undefined-or-void', + allowBytes, ) const completed = new Map() const active = new Map() @@ -1617,6 +1622,9 @@ class FaceAnalyzer { return id } const flags = type.flags + if (this.isRemoteByteArray(type)) { + return add({ kind: 'reference', name: 'Uint8Array', target: { kind: 'standard', name: 'Uint8Array' }, arguments: [] }) + } if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' }) if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' }) if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' }) @@ -1654,7 +1662,7 @@ class FaceAnalyzer { if ((flags & ts.TypeFlags.TypeParameter) !== 0) { this.fail(authoredType, 'Remote codec contains an unresolved type parameter') } - if ((flags & ts.TypeFlags.Object) === 0) { + if ((flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) === 0) { this.fail( authoredType, `Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`, @@ -1669,7 +1677,9 @@ class FaceAnalyzer { elements: arguments_.map((argument, index) => { const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required return { - type: convert(argument), + type: (elementFlags & ts.ElementFlags.Rest) !== 0 + ? this.addNode(authoredType, { kind: 'array', element: convert(argument) }) + : convert(argument), optional: (elementFlags & ts.ElementFlags.Optional) !== 0, rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0, } @@ -1748,6 +1758,7 @@ class FaceAnalyzer { active: Set, allowUndefined: boolean, allowVoid: boolean, + allowBytes = false, ): void { const flags = type.flags if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return @@ -1763,22 +1774,26 @@ class FaceAnalyzer { | ts.TypeFlags.BooleanLike | ts.TypeFlags.Null | ts.TypeFlags.Never)) !== 0) return + if (this.isRemoteByteArray(type)) { + if (allowBytes) return + this.fail(site, 'Remote Uint8Array is only supported in unary results') + } if (type.isUnion()) { for (const member of type.types) { - this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid) + this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid, allowBytes) } return } if (type.isIntersection()) { const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') - for (const member of material) this.assertRemoteJsonType(member, site, active, false, false) + for (const member of material) this.assertRemoteJsonType(member, site, active, false, false, allowBytes) return } if ((flags & ts.TypeFlags.TypeParameter) !== 0) { this.fail(site, 'Remote boundary contains an unresolved type parameter') } - if ((flags & ts.TypeFlags.Object) === 0) { + if ((flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) === 0) { this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) } const symbol = type.getSymbol() @@ -1804,6 +1819,7 @@ class FaceAnalyzer { active, (elementFlags & ts.ElementFlags.Optional) !== 0, false, + allowBytes, ) }) return @@ -1811,7 +1827,7 @@ class FaceAnalyzer { if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) if (element === undefined) this.fail(site, 'Remote boundary array has no element type') - this.assertRemoteJsonType(element, site, active, false, false) + this.assertRemoteJsonType(element, site, active, false, false, allowBytes) return } const properties = this.checker.getPropertiesOfType(type) @@ -1827,19 +1843,26 @@ class FaceAnalyzer { active, (property.flags & ts.SymbolFlags.Optional) !== 0, false, + allowBytes, ) } for (const info of this.checker.getIndexInfosOfType(type)) { if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { this.fail(site, 'Remote boundary contains a symbol index signature') } - this.assertRemoteJsonType(info.type, site, active, false, false) + this.assertRemoteJsonType(info.type, site, active, false, false, allowBytes) } } finally { active.delete(type) } } + private isRemoteByteArray(type: ts.Type): boolean { + const symbol = type.getSymbol() + return symbol?.name === 'Uint8Array' + && symbol.declarations?.some(declaration => isStandardLibraryFile(declaration.getSourceFile().fileName)) === true + } + private includesRemoteAbsence(type: ts.Type): boolean { if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) return true return type.isUnion() && type.types.some(member => this.includesRemoteAbsence(member)) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index f7c1beaf71..78907abd5f 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -22,6 +22,7 @@ import type { TypeNodeModel, } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' +import { childTypeNodeIds } from './model.ts' /** Failure to project a modeled construct into an emitted artifact. */ export class TypertEmitError extends Error { @@ -331,6 +332,7 @@ export class FaceModelEmitter { lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), + schemas.encoder(resultBoundaryKey(invocation)), ), 2).trimStart()},`) lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) lines.push('}') @@ -366,6 +368,12 @@ 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 extends Uint8Array ? Uint8Array') + lines.push(' : Value extends string | number | boolean | null | undefined ? Value') + lines.push(' : { [Key in keyof Value]: RemoteDecoded }') + 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 => @@ -478,7 +486,10 @@ export class FaceModelEmitter { !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') - const result = this.renderer.renderType(invocation.result.type, referenceNames) + const authoredResult = this.renderer.renderType(invocation.result.type, referenceNames) + const result = containsBytes(this.renderer, invocation.result.codecType) + ? `RemoteDecoded<${authoredResult}>` + : authoredResult if (invocation.mode === 'stream') { const uplink = invocation.uplink === undefined ? 'never' @@ -519,6 +530,7 @@ interface SchemaArtifact { readonly definitions: readonly string[] readonly exports: readonly SchemaExport[] boundary(key: string): string + encoder(key: string): string | undefined } interface BoundarySchemaRoot { @@ -526,6 +538,118 @@ interface BoundarySchemaRoot { readonly type: TypeNodeId } +class ResultEncoderEmitter { + private readonly names = new Map() + private readonly bodies = new Map() + + constructor(private readonly renderer: TypeGraphRenderer) {} + + reference(id: TypeNodeId): string | undefined { + if (!containsBytes(this.renderer, id)) return undefined + const existing = this.names.get(id) + if (existing !== undefined) return existing + const name = `$encode${this.names.size}` + this.names.set(id, name) + const node = this.renderer.node(id) + const container = node.kind === 'object' || node.kind === 'array' || node.kind === 'tuple' + const body = container || node.kind === 'union' || node.kind === 'intersection' + ? `return $resultContainer(value, path, ancestors, value => {\n${indent(this.body(id), 2)}\n}, ${container})` + : this.body(id) + this.bodies.set(id, `const ${name} = (value, writeBytes, path, ancestors) => {\n${indent(body, 2)}\n}`) + return name + } + + definitions(): string[] { + return this.bodies.size === 0 ? [] : [RESULT_SNAPSHOT, ...this.bodies.values()] + } + + private call(id: TypeNodeId, value: string, path = 'path'): string { + const reference = this.reference(id) + return reference === undefined ? value : `${reference}(${value}, writeBytes, ${path}, ancestors)` + } + + private body(id: TypeNodeId): string { + const node = this.renderer.node(id) + switch (node.kind) { + case 'reference': { + if (node.target.kind === 'standard' && node.target.name === 'Uint8Array') { + return 'return value instanceof Uint8Array ? writeBytes(value, path) : value' + } + if (node.target.kind === 'declaration') { + const type = this.renderer.declaration(node.target.symbol).type + if (type !== undefined) return `return ${this.call(type, 'value')}` + } + break + } + case 'union': + case 'intersection': return [ + ...node.types.filter(type => containsBytes(this.renderer, type)).map(type => `value = ${this.call(type, 'value')}`), + 'return value', + ].join('\n') + case 'array': return [ + 'if (!Array.isArray(value)) return value', + `for (let index = 0; index < value.length; index++) value[index] = ${this.call(node.element, 'value[index]', '[...path, index]')}`, + 'return value', + ].join('\n') + case 'tuple': { + const lines = ['if (!Array.isArray(value)) return value'] + for (const [index, element] of node.elements.entries()) { + if (!containsBytes(this.renderer, element.type)) continue + if (element.rest) { + const rest = this.renderer.node(element.type) + if (rest.kind !== 'array') break + lines.push(`for (let index = ${index}; index < value.length; index++) value[index] = ${this.call(rest.element, 'value[index]', '[...path, index]')}`) + } else { + lines.push(`if (value.length > ${index}) value[${index}] = ${this.call(element.type, `value[${index}]`, `[...path, ${index}]`)}`) + } + } + return [...lines, 'return value'].join('\n') + } + case 'object': { + const lines = ["if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Uint8Array) return value"] + for (const member of node.members) { + if (member.kind === 'property' && containsBytes(this.renderer, member.type)) { + const key = quote(member.name) + lines.push(`if (Object.hasOwn(value, ${key})) value[${key}] = ${this.call(member.type, `value[${key}]`, `[...path, ${key}]`)}`) + } else if (member.kind === 'index' && containsBytes(this.renderer, member.signature.returns)) { + lines.push(`for (const key of Object.keys(value)) value[key] = ${this.call(member.signature.returns, 'value[key]', '[...path, key]')}`) + } + } + return [...lines, 'return value'].join('\n') + } + default: break + } + throw new TypertEmitError(`typert result encoder: ${node.kind} has no binary field projection`) + } +} + +const RESULT_SNAPSHOT = `const $resultSnapshot = (input, path) => { + if (input === null || typeof input !== 'object' || input instanceof Uint8Array) return input + const toJSON = input.toJSON + const value = typeof toJSON === 'function' ? toJSON.call(input, path.at(-1)?.toString() ?? 'value') : input + if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return value + if (Array.isArray(value)) { + const items = [] + for (let index = 0, length = value.length; index < length; index++) items.push(value[index]) + return items + } + const fields = {} + for (const key of Object.keys(value)) { + const item = key === 'toJSON' && value === input ? toJSON : value[key] + if (key === 'toJSON' && typeof item === 'function') continue + Object.defineProperty(fields, key, { value: item, enumerable: true, writable: true, configurable: true }) + } + return fields +} +const $resultContainer = (input, path, ancestors, project, snapshot) => { + if (input === null || typeof input !== 'object') return project(input) + const owner = !ancestors.has(input) + if (!owner && ancestors.get(input) !== path.length) throw new TypeError('Remote result contains a circular object') + if (owner) ancestors.set(input, path.length) + try { return project(snapshot ? $resultSnapshot(input, path) : input) } + finally { if (owner) ancestors.delete(input) } +}` + class SchemaEmitter { private readonly names = new Map() private readonly boundaryNames = new Map() @@ -569,10 +693,15 @@ class SchemaEmitter { emit(): SchemaArtifact { const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration)) + const encoder = new ResultEncoderEmitter(this.renderer) + const encoders = new Map() for (const boundary of this.boundaries) { + 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(...encoder.definitions()) const exports = this.schemas.map((model): SchemaExport => ({ model, exportName: safeIdentifier(model.export.name), @@ -582,6 +711,7 @@ class SchemaEmitter { definitions, exports, boundary: key => this.boundaryName(key), + encoder: key => encoders.get(key), } } @@ -699,6 +829,7 @@ class SchemaEmitter { return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})` } case 'Date': return 'z.date()' + case 'Uint8Array': return 'z.instanceof(Uint8Array)' default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) } } @@ -773,8 +904,11 @@ class SchemaEmitter { 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(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional), + this.optional(schema, member.optional), member, ) properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`) @@ -890,12 +1024,40 @@ function resultBoundaryKey(invocation: InvocationModel): string { return `${invocation.id}:result` } -function strictCodec(boundary: RemoteBoundaryModel, schema: string): string { +function containsBytes( + renderer: TypeGraphRenderer, + id: TypeNodeId, + nested = true, + visited = new Set(), +): boolean { + if (visited.has(id)) return false + visited.add(id) + const node = renderer.node(id) + if (node.kind === 'reference') { + if (node.target.kind === 'standard' && node.target.name === 'Uint8Array') return true + if (node.target.kind === 'declaration') { + const declaration = renderer.declaration(node.target.symbol) + if (declaration.type !== undefined && containsBytes(renderer, declaration.type, nested, visited)) return true + } + if (!nested) return false + } + if (!nested && node.kind !== 'reference' && node.kind !== 'parenthesized' + && node.kind !== 'union' && node.kind !== 'intersection') return false + if (node.kind === 'object' && node.members.some(member => + containsBytes(renderer, member.kind === 'property' ? member.type : member.signature.returns, nested, visited))) return true + return childTypeNodeIds(node).some(child => containsBytes(renderer, child, nested, visited)) +} + +function strictCodec(boundary: RemoteBoundaryModel, schema: string, encoder?: string): string { return [ '{', ' mode: \'strict\',', ` typeSymbol: ${quote(boundary.typeSymbol)},`, ` create: ${schema},`, + ...(encoder === undefined ? [] : [ + ` decode: value => ${schema}().parse(value),`, + ` encode: (value, writeBytes) => ${encoder}(value, writeBytes, [], new Map()),`, + ]), '}', ].join('\n') } @@ -923,7 +1085,13 @@ function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImpor } function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { - const used = new Set(['RemoteStreamHandle', 'TypertRemoteContribution', 'TYPERT_REMOTE']) + const used = new Set([ + 'RemoteDecoded', + 'RemoteResult', + 'RemoteStreamHandle', + 'TypertRemoteContribution', + 'TYPERT_REMOTE', + ]) const names = new Map() for (const imported of imports) { const base = safeIdentifier(imported.name) diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index e291df692c..e8e69be85f 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -15,7 +15,7 @@ function normalizedPath(path: string): string { } interface RuntimeSchema { - safeParse(value: unknown): { readonly success: boolean } + safeParse(value: unknown): { readonly success: boolean; readonly data?: unknown } } interface RuntimeDescriptor { @@ -30,7 +30,11 @@ interface RuntimeDescriptor { readonly uplink?: { readonly codec: { readonly create: () => RuntimeSchema } } - readonly result: { readonly create: () => RuntimeSchema } + readonly result: { + readonly create: () => RuntimeSchema + readonly decode?: (value: unknown) => unknown + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + } } interface RuntimeRemoteModule { @@ -51,6 +55,234 @@ afterEach(() => { }) describe('Remote model generation', { timeout: 60_000 }, () => { + it('projects a generic binary result without copying or freezing its bytes', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport type BinaryFile = {\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( + '\n}\n\nexport type {', + '\n @Remote\n bytes(): BinaryFile { return { data: new Uint8Array([0, 255]), size: 2 } }\n}\n\nexport type {', + )}`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.js).not.toContain('binaryResult') + expect(artifact?.remote?.dts).toContain('Promise>>') + const generated = await import(`data:text/javascript,${encodeURIComponent( + (artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`), + )}`) as RuntimeRemoteModule + 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') + 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 }) + expect(parsed.success).toBe(true) + expect((parsed.data as { readonly data: Uint8Array }).data).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + } + expect(schema.safeParse({ data: 'AP8=', size: 2 }).success).toBe(false) + expect(schema.safeParse({ data: [0, 255], size: 2 }).success).toBe(false) + expect(schema.safeParse({ data: new Uint8Array(), size: '0' }).success).toBe(false) + expect(schema.safeParse({ size: 0 }).success).toBe(false) + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root, ` +async function readBytes() { + const result = await ctx.remote.goals.bytes() + if (!result.ok) return + const data: Uint8Array = result.value.data + new Blob([data]) + // @ts-expect-error binary results do not carry base64 strings. + const base64: string = result.value.data + // @ts-expect-error the Client bytes have ordinary ArrayBuffer backing. + const shared: Uint8Array = result.value.data + void base64 + void shared +} +void readBytes +`) + }) + + it.each([ + ['parameter', '@Remote\n bytes(data: Uint8Array): number { return data.length }'], + ['nested parameter', '@Remote\n bytes(files: { content: Uint8Array }[]): number { return files.length }'], + ['binary stream', "@Remote({ mode: 'stream' })\n async *bytes(): AsyncIterable<{ data: Uint8Array }> { throw new Error() }"], + ])('rejects unsupported binary %s', (_name, method) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', `\n ${method}\n}\n\nexport type {`, + )) + expect(() => new WorkspaceTypertGenerator(root).generate()) + .toThrow('Remote Uint8Array is only supported in unary results') + }) + + it.each([ + ['undefined data', '{ data: Uint8Array | undefined }', 'Remote boundary contains non-JSON type undefined'], + ['unconstrained metadata', '{ data: Uint8Array; meta: unknown }', 'Remote boundary contains unconstrained unknown data'], + ])('rejects binary results with %s', (_name, type, message) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', `\n @Remote\n bytes(): ${type} { throw new Error() }\n}\n\nexport type {`, + )) + expect(() => new WorkspaceTypertGenerator(root).generate()).toThrow(message) + }) + + it('decodes recursive objects, arrays, tuples, unions and root bytes by their field types', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} +declare const fileNameBrand: unique symbol +export type FileName = string & { readonly [fileNameBrand]: true } +export type LinkedBytes = { readonly content: Uint8Array } & { readonly next?: LinkedBytes } +export interface BinaryTree { + readonly name: FileName + readonly content?: Uint8Array | null + readonly files: readonly BinaryTree[] + readonly pair: readonly [Uint8Array, { readonly raw: Uint8Array }?] + readonly tail: readonly [string, ...Uint8Array[]] + readonly choice: Uint8Array | { readonly preview: Uint8Array } + readonly variants: ({ readonly content: Uint8Array } & { readonly content: Uint8Array; readonly size: number })[] + readonly chunks: Readonly> +} +`) + editFile(root, 'packages/remote/src/index.ts', source => `import type { BinaryTree, LinkedBytes } from './types.ts'\n${source.replace( + '\n}\n\nexport type {', ` + @Remote + tree(): BinaryTree { throw new Error() } + @Remote + linked(): LinkedBytes { throw new Error() } + @Remote + raw(): Uint8Array { throw new Error() } + @Remote + nullable(): Uint8Array | null { throw new Error() } + @Remote + maybe(): Uint8Array | void { throw new Error() } + @Remote + array(): readonly Uint8Array[] { throw new Error() } + @Remote + envelope(): { readonly content?: Uint8Array; readonly metadata: { readonly title: string } } { throw new Error() } + @Remote + overlap(): { content: Uint8Array } | { content: Uint8Array; title: string } { throw new Error() } + @Remote + objectOrBytes(): { content: Uint8Array } | Uint8Array { throw new Error() } +}\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 codec = (method: string) => { + const descriptor = generated.TYPERT_REMOTE.descriptors.find(item => item.id.endsWith(`/${method}`)) + if (descriptor === undefined) throw new Error(`descriptor missing: ${method}`) + return descriptor.result + } + const content = new Uint8Array([128, 255]) + const leaf = { name: 'leaf', files: [], pair: [content], tail: ['parts', content], choice: content, variants: [], chunks: {} } + const value = { + ...leaf, content, files: [{ ...leaf, content: null }], pair: [content, { raw: content }], + choice: { preview: content }, variants: [{ content, size: 2 }], chunks: { 'a.b/c': content }, + } + expect(codec('tree').decode?.(value)).toEqual(value) + const parsed = codec('tree').decode?.(value) as typeof value + expect(parsed.content).toBe(content) + expect(Object.isFrozen(parsed.files)).toBe(true) + expect(Object.isFrozen(parsed.pair)).toBe(true) + expect(parsed.pair[0]).toBe(content) + expect(parsed.variants[0]?.content).toBe(content) + expect(parsed.tail[1]).toBe(content) + expect(Object.isFrozen(content)).toBe(false) + expect(codec('tree').decode?.(leaf)).toEqual(leaf) + expect(() => codec('tree').decode?.({ ...value, files: [{ ...leaf, content: 'base64' }] })).toThrow() + expect(() => codec('tree').decode?.({ ...value, variants: [{ content, size: '2' }] })).toThrow() + expect(codec('raw').decode?.(content)).toBe(content) + expect(codec('nullable').decode?.(null)).toBe(null) + expect(codec('nullable').decode?.(content)).toBe(content) + expect(codec('maybe').decode?.(undefined)).toBeUndefined() + expect(codec('maybe').decode?.(content)).toBe(content) + expect(() => codec('raw').decode?.([128, 255])).toThrow() + expect(codec('array').decode?.([content])).toEqual([content]) + expect((codec('array').decode?.([content]) as Uint8Array[])[0]).toBe(content) + expect(codec('create').decode).toBeUndefined() + expect(codec('create').encode).toBeUndefined() + const attachments: { bytes: Uint8Array; path: readonly (string | number)[] }[] = [] + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ bytes, path }) + return null + } + const metadata = codec('tree').encode?.(value, writeBytes) as typeof value + expect(attachments.map(item => item.path)).toEqual([ + ['content'], ['files', 0, 'pair', 0], ['files', 0, 'tail', 1], ['files', 0, 'choice'], + ['pair', 0], ['pair', 1, 'raw'], ['tail', 1], ['choice', 'preview'], + ['variants', 0, 'content'], ['chunks', 'a.b/c'], + ]) + expect(attachments.every(item => item.bytes === content)).toBe(true) + expect(metadata.content).toBeNull() + expect(metadata.files[0]?.content).toBeNull() + expect(metadata.variants[0]?.content).toBeNull() + expect(value.content).toBe(content) + attachments.length = 0 + expect(codec('overlap').encode?.({ content, title: 'image' }, writeBytes)).toEqual({ content: null, title: 'image' }) + expect(attachments).toEqual([{ bytes: content, path: ['content'] }]) + attachments.length = 0 + const augmented = Object.assign(new Uint8Array(new SharedArrayBuffer(1)).fill(127), { content }) + expect(codec('objectOrBytes').encode?.(augmented, writeBytes)).toBeNull() + expect(augmented.content).toBe(content) + expect(attachments).toEqual([{ bytes: augmented, path: [] }]) + attachments.length = 0 + expect(codec('raw').encode?.(content, writeBytes)).toBeNull() + expect(attachments).toEqual([{ bytes: content, path: [] }]) + expect(codec('maybe').encode?.(undefined, writeBytes)).toBeUndefined() + const circular: { files: unknown[] } = { ...leaf, files: [] } + circular.files.push(circular) + expect(() => codec('tree').encode?.(circular, writeBytes)).toThrow('circular object') + const linked: { content: Uint8Array; next?: unknown } = { content } + linked.next = linked + expect(() => codec('linked').encode?.(linked, writeBytes)).toThrow('circular object') + let metadataReads = 0 + let contentReads = 0 + const opaque = { get title() { metadataReads++; return 'image' } } + const optional = { get content() { contentReads++; return undefined }, metadata: opaque } + const absent = codec('envelope').encode?.(optional, writeBytes) as typeof optional + expect(absent.metadata).toBe(opaque) + expect(metadataReads).toBe(0) + expect(JSON.stringify(absent)).toBe('{"metadata":{"title":"image"}}') + expect(contentReads).toBe(1) + expect(metadataReads).toBe(1) + let jsonCalls = 0 + const projected = codec('envelope').encode?.({ toJSON(key: string) { + jsonCalls++ + expect(key).toBe('value') + return { content, metadata: { title: 'image' } } + } }, writeBytes) + expect(JSON.stringify(projected)).toBe('{"content":null,"metadata":{"title":"image"}}') + expect(jsonCalls).toBe(1) + const dictionary = codec('tree').encode?.({ + ...leaf, chunks: Object.fromEntries([['__proto__', content]]), + }, writeBytes) as typeof value + expect(Object.hasOwn(dictionary.chunks, '__proto__')).toBe(true) + expect((dictionary.chunks as Record)['__proto__']).toBeNull() + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root, ` +import type { BinaryTree, FileName } from '@fixture/remote/types' +async function binaryTree() { + const result = await ctx.remote.goals.tree() + if (!result.ok) return + const tree: BinaryTree = result.value + const name: FileName = result.value.name + const leaf = result.value.files[0]! + if (leaf.content) new Blob([leaf.content]) + const pair: readonly [Uint8Array, { readonly raw: Uint8Array }?] = leaf.pair + const choice: Uint8Array | { readonly preview: Uint8Array } = leaf.choice + const chunk: Uint8Array = leaf.chunks['file']! + // @ts-expect-error array readonly modifier is preserved. + result.value.files.push(leaf) + // @ts-expect-error property readonly modifier is preserved. + result.value.name = name + const raw = await ctx.remote.goals.raw() + if (raw.ok) new Blob([raw.value]) + const maybe: Promise | void>> = ctx.remote.goals.maybe() + const absent = await maybe + if (absent.ok && absent.value !== undefined) new Blob([absent.value]) + void tree; void pair; void choice; void chunk +} +void binaryTree +`) + }) + it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => { const generator = new WorkspaceTypertGenerator(fixtureRoot) @@ -740,6 +972,7 @@ function assertRemoteConsumerTypechecks( dts: string | undefined, dtsMap: string | undefined, sourceRoot = fixtureRoot, + extraConsumer = '', ): void { if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') @@ -782,6 +1015,7 @@ void createdScoped void renamed void watched void navigated +${extraConsumer} ` writeFileSync(consumerPath, consumerSource) const configPath = join(consumerRoot, 'tsconfig.consumer.json') diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 004a2fdc17..50ec19165d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -273,6 +273,11 @@ function requireStrictCodec(pkgName: string, value: unknown, subject: string): v throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`) } requireString(pkgName, codec, 'typeSymbol', subject) + for (const method of ['decode', 'encode']) { + if (codec[method] !== undefined && typeof codec[method] !== 'function') { + 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`) } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 748f005d4a..72db60cbae 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -535,6 +535,14 @@ describe('validateTypertManifest', () => { const descriptor = strictInvocation() const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const decoded = { ...descriptor, result: { ...descriptor.result, decode: (value: unknown) => value } } + expect(validateTypertManifest('pkg', { ...base, invocations: [decoded] }).invocations).toEqual([decoded]) + expect(() => validateTypertManifest('pkg', { + ...base, invocations: [{ ...decoded, result: { ...decoded.result, decode: true } }], + })).toThrow('decode must be a function') + expect(() => validateTypertManifest('pkg', { + ...base, invocations: [{ ...decoded, result: { ...decoded.result, encode: true } }], + })).toThrow('encode must be a function') const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } } expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations) .toEqual([cancellable]) diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 8a68e37489..d1073f7952 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/protocol/README.md -README.md: e96c53fb2c8ad671fce1bb25c235c06517fc3d6b -README.zh.md: e9cb0f8496683c12ea009530a6cf97bdd023ee24 +README.md: 8a97bb339d462c8dbea7666c7855a7e51ae9b113 +README.zh.md: 9bb2cae765ee5747bf11a92d44ec2bb201b0285d diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index e96c53fb2c..8a97bb339d 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. +A unary method can return `Uint8Array` directly or within nested objects, arrays, tuples, optional fields, unions, and recursive types. Generation supplies optional result codec `encode()` and `decode()` functions: encoding visits only subtrees whose types can contain bytes, while decoding validates reconstructed values; Client declarations use `Uint8Array` at every byte position while retaining other field types. Pure JSON results pass through without Host byte detection or Client parsing. Parameters, events, and stream items remain JSON-only; runtime object cycles are unsupported. + A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, 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`: 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. ### Associating Host objects and Contexts with wire identities diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index e9cb0f8496..9bb2cae765 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 +一元方法可以直接返回 `Uint8Array`,也可以将其放在嵌套对象、数组、元组、可选字段、联合类型和递归类型中。生成器提供可选的结果 codec `encode()` 和 `decode()`:编码仅访问类型可能包含字节的子树,解码则校验还原后的值;Client 声明在每个字节位置使用 `Uint8Array`,同时保留其他字段类型。纯 JSON 结果不经 Host 字节识别或 Client 解析直接传递。参数、事件与流条目仍仅支持 JSON;不支持运行时对象循环。 + 流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。 ### 把 Host 对象与 Context 关联到 wire identity diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 14917682b7..ec2a88c4f9 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -273,6 +273,19 @@ export type TypertCodec = 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 + /** + * 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 mode: 'src-json' diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 9f9b9c54cb..9df0c730e8 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -99,6 +99,18 @@ function scopedInvocation(): InvocationDescriptor { } describe('TypertRegistry', () => { + it('retains generated result decoders until the contribution is disposed', async () => { + const ctx = await makeCtx() + const decode = (value: unknown) => value + const descriptor: InvocationDescriptor = { + ...invocation(), result: { mode: 'strict', typeSymbol: 'fixture#bytes', create: () => ({ parse: decode }), decode }, + } + const dispose = ctx.typert.register({ ...toolsContribution(), invocations: [descriptor] }) + expect(ctx.typert.local.get('goals/create')?.result).toBe(descriptor.result) + await dispose() + expect(ctx.typert.local.get('goals/create')).toBeUndefined() + }) + it('registers and queries generated schemas separately from package reflection', async () => { const ctx = await makeCtx() const contribution = toolsContribution() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a3a2d3f04..8663d19a99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1427,6 +1427,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-client-resources': specifier: workspace:^ version: link:../../client/resources @@ -2369,6 +2372,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../extensions/tool-cordis '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../subagent/tool-subagent @@ -3051,6 +3057,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8e6b094b10..b0d76c28ee 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -855,6 +855,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkspaceFileScope: 'Host workspace file lookup contract is owned by packages/api/workspace-files/README.md', + WorkspaceByteReadOptions: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 506a8110b5..9d669c2bfd 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", diff --git a/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml b/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml index 05682eb693..1119ac8f98 100644 --- a/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml +++ b/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml @@ -36,6 +36,8 @@ - insert: - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' + - id: cordis-inspect-providers + name: '@deepseek-ai/dsh-tool-cordis/host' - id: tool-cordis name: '@deepseek-ai/dsh-tool-cordis' - id: llm-replay diff --git a/snapshots/session/cordis-inspect-jsdoc/cordis.yml b/snapshots/session/cordis-inspect-jsdoc/cordis.yml index 1f92269096..9c8e871aea 100644 --- a/snapshots/session/cordis-inspect-jsdoc/cordis.yml +++ b/snapshots/session/cordis-inspect-jsdoc/cordis.yml @@ -33,5 +33,7 @@ - insert: - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' + - id: cordis-inspect-providers + name: '@deepseek-ai/dsh-tool-cordis/host' - id: tool-cordis name: '@deepseek-ai/dsh-tool-cordis' diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index 158f24c1af..ac4c592e35 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -25,10 +25,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. -# Harness runtime inspection - -Use cordis_inspect_list to discover Host and Client providers, then cordis_inspect_query to read exact Service, Event, Tool, Theme or Slot APIs. These tools are read-only; queries do not invoke business methods. - Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. @@ -71,7 +67,7 @@ interface ToolArgsMap { } & Record; /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */ cordis_inspect_list: Record; - /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant. */ + /** Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. */ cordis_inspect_query: { /** Runtime platform that owns the Provider. */ platform: "host" | "client"; diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index a48efa53bf..532e048e81 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -55,7 +55,7 @@ }, { "name": "cordis_inspect_query", - "description": "Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant.", + "description": "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/session/skill-load/session.v4.jsonl b/snapshots/session/skill-load/session.v4.jsonl new file mode 100644 index 0000000000..62c83cec58 --- /dev/null +++ b/snapshots/session/skill-load/session.v4.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783654655599,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `editing-cordis-compositions`: Use when creating, changing, or validating an agent preset or other Cordis composition for this harness, including deciding host versus preset placement and checking that an authored preset mounts.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"editing-cordis-compositions","description":"Use when creating, changing, or validating an agent preset or other Cordis composition for this harness, including deciding host versus preset placement and checking that an authored preset mounts."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Load the editing-cordis-compositions ski","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"editing-cordis-compositions\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile.\n\n## Authoring a preset\n\nUse shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods.\n\nCopy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source.\n\nEdit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance.\n\n## Service isolation\n\nA preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm.\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflowEngine: true\n config:\n - id: workflow-ptc\n name: '@deepseek-ai/dsh-workflow-ptc'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n## Verify a preset\n\nCheck the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index a6bd84f32d..187a1abeef 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -2,7 +2,7 @@ You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-v4-flash model. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/cordis-tool-round/tool-schemas.expected.json b/snapshots/web/cordis-tool-round/tool-schemas.expected.json index ca7afe0add..bb6f987d5a 100644 --- a/snapshots/web/cordis-tool-round/tool-schemas.expected.json +++ b/snapshots/web/cordis-tool-round/tool-schemas.expected.json @@ -195,7 +195,7 @@ }, { "name": "cordis_inspect_query", - "description": "Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant.", + "description": "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime.", "parameters": { "type": "object", "properties": { diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index cc5a4013c4..e05a41da44 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -2,7 +2,7 @@ You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-v4-flash model. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 8787372c8f..107c581adc 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -4,7 +4,7 @@ You are a coding agent powered by the deepseek-v4-flash model. `run_code` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/schedule-catalog/system-prompt.expected.md b/snapshots/web/schedule-catalog/system-prompt.expected.md index 0c5f3c77cc..01b43eca97 100644 --- a/snapshots/web/schedule-catalog/system-prompt.expected.md +++ b/snapshots/web/schedule-catalog/system-prompt.expected.md @@ -6,7 +6,7 @@ You are interacting with the user through the DeepSeek Harness Web GUI at {{webU You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. -Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. +Tokens prefixed with @ are paths the user explicitly referenced. Relative paths resolve from the workspace root; absolute paths identify files or directories on the host. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces. Check the [exit code: N] marker on every bash result; investigate failures before moving on. diff --git a/snapshots/web/skill-tool-row/snapshot.yml b/snapshots/web/skill-tool-row/snapshot.yml index 4c82c05edb..8b01149257 100644 --- a/snapshots/web/skill-tool-row/snapshot.yml +++ b/snapshots/web/skill-tool-row/snapshot.yml @@ -6,4 +6,4 @@ recording: authored header: class: skill session: - source: ../../session/skill-load/session.v3.jsonl + source: ../../session/skill-load/session.v4.jsonl diff --git a/tsconfig.base.json b/tsconfig.base.json index 60c97b6946..313fd9eeff 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -185,6 +185,7 @@ "@deepseek-ai/dsh-plugin-manager/registry": ["./packages/boot/plugin-manager/src/registry.ts"], "@deepseek-ai/dsh-plugin-manager/operations": ["./packages/boot/plugin-manager/src/operations.ts"], "@deepseek-ai/dsh-plugin-manager/tools": ["./packages/boot/plugin-manager/src/tools.ts"], + "@deepseek-ai/dsh-tool-cordis/host": ["./packages/extensions/tool-cordis/src/host.ts"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],