From 4806fdabab13bf8ca9130848e7461d5be1f7e316 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 23:13:28 +0800 Subject: [PATCH 01/46] fix(tools): collapse code-mode executor to run_code for model-direct calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireSchemas() already advertised only run_code under mode: 'code', but the executor resolved every call through get(), which returns the full visible map plus the reserved transport. A model could name a native tool directly and bypass run_code entirely. Route the execution-path lookups through a new private resolveExecution() that applies the mode collapse at the operation boundary: model-direct calls under 'code' may only name run_code (UNKNOWN_TOOL otherwise), while SDK sub-dispatches (parent token set) keep every visible tool. get()/schemas() public semantics are unchanged. The denial happens at createExecution, before the extensible policy pipeline — pre-execute listeners, approval ask, and guards never observe a call that is deterministically denied. A collapsed call honors the pre-dispatch cancellation contract, routes aborted results through the visible tool's finalizeContent, and captures the finalizer before argument materialization. Under code mode, a system-prompt/assemble listener filters out tool:* guidance sections that told the model to call native tools directly. The tools:sdk section and SDK types remain so programs can still use all tools through run_code. Fixes #1815 --- ...8-07-code-mode-executor-collapse.i18n.yaml | 6 ++ .../2026-08-07-code-mode-executor-collapse.md | 45 +++++++++++ ...26-08-07-code-mode-executor-collapse.zh.md | 45 +++++++++++ .../core/agent-loop/tests/tool-calls.spec.ts | 76 ++++++++++++++++- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 4 +- packages/core/tools/README.zh.md | 4 +- packages/core/tools/src/index.ts | 81 ++++++++----------- packages/core/tools/tests/tools.spec.ts | 21 ++++- 9 files changed, 232 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml new file mode 100644 index 0000000000..b81b0fe922 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +2026-08-07-code-mode-executor-collapse.md: 817da95678edbceb807c45b3ef858b800eaaa6c2 +2026-08-07-code-mode-executor-collapse.zh.md: 7b4a1b8adc01f6611c50cc4325c02d4b60313898 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md new file mode 100644 index 0000000000..817da95678 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md @@ -0,0 +1,45 @@ +# Agent Note: Code Mode collapses the executor, not just the wire + +Status: implemented + +English | [中文](2026-08-07-code-mode-executor-collapse.zh.md) + +## Problem + +`mode: 'code'` collapsed only the announcement surface, not the execution surface. `wireSchemas()` sent the model exactly one tool — `run_code` — but the executor resolved every call through `get()`, which returns the full visible map plus the reserved transport. A model that emitted a native tool name (`write`, `read`, `bash`, `subagent`, …) bypassed `run_code` entirely: the call traversed the normal pipeline and executed, even though no schema for it had ever been advertised. Providers do not intercept unadvertised tool names, so schema omission enforced nothing. + +The package contract names this exact anti-pattern: schema omission is not enforcement when a direct caller can bypass it; denial must be tested through the executor. + +## Decision + +`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. A model-direct call (`nested = false`) under `code` may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. + +Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not. + +The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `code` returns `UNKNOWN_TOOL`, the same tool via an SDK sub-dispatch succeeds, and `native`/`both` direct calls plus `run_code` itself are unchanged. The base [Code Mode foundation](../feature/2026-06-15-code-mode.md) owns the transport design this note layers the execution boundary onto. + +## Alternatives considered + +### Filter `get()` / the registry view by mode + +The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder; collapsing it would hide from the program surface tools that must still bind, and would change the public resolution contract for every consumer, not just the executor. + +### Filter at the agent-loop entry + +The loop is not the only executor caller, and the distinction that matters (model-direct vs transport sub-dispatch) rides on the execution input, not at the loop boundary. An entry filter would also re-encode mode semantics the registry already owns. + +### Reject via a shipped guard + +Guards are an optional plugin extension; a security invariant must not depend on a deployment composing the right plugin. The registry owns the mode decision and must enforce it itself. + +### Keep schema omission only (status quo) + +No provider guarantees interception of unadvertised names; the reported session proves it does not happen. + +## Consequences + +- `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract). +- `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). +- A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. +- Under `code` mode, the imperative native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) are filtered from the system prompt by a `system-prompt/assemble` listener so the model is never told to call a tool it cannot reach directly. The `tools:sdk` section (TypeScript bindings) remains, so programs can still use every tool through `run_code`. +- Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md new file mode 100644 index 0000000000..7b4a1b8adc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Code Mode 塌缩执行器而非仅通告面 + +Status: implemented + +[English](2026-08-07-code-mode-executor-collapse.md) | 中文 + +## 问题 + +`mode: 'code'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 + +包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。 + +## 决策 + +`ToolRegistry` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。`code` 模式下,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 + +执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。 + +塌缩是安全相关的不变量,因此验收经执行器钉死:`code` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [Code Mode 基础](../feature/2026-06-15-code-mode.md) 之上,传输设计由后者拥有。 + +## 备选方案 + +### 按模式过滤 `get()` / 注册表视图 + +视图被展示方、`tool-cordis` 检查与 SDK 绑定消费;塌缩视图会从程序表面隐藏仍必须绑定的工具,并改变所有消费者的公共解析契约,而不只是执行器。 + +### 在 agent-loop 入口过滤 + +loop 不是唯一的执行器调用方,且真正要紧的区分(模型直呼 vs 传输子调用)挂在执行输入上,不在 loop 边界。入口过滤还会重复编码注册表已经拥有的模式语义。 + +### 通过内置 guard 拒绝 + +guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装了正确的插件。模式决策归注册表所有,必须由它自己执行。 + +### 只保留 schema 省略(维持现状) + +没有提供方保证拦截未通告的名字;被报告的会话证明拦截不会发生。 + +## 后果 + +- `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 +- `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 +- 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 +- 在 `code` 模式下,命令式原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)现在通过 `system-prompt/assemble` 监听器从系统提示词中过滤,模型不会再被告知去直接调用它无法触达的工具。`tools:sdk` 段(TypeScript 绑定)保留,程序仍可通过 `run_code` 使用所有工具。 +- 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 972c321138..10cbf9f056 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context } from 'cordis' import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -13,6 +13,8 @@ import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, T import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { const ctx = new Context() @@ -687,3 +689,75 @@ describe('tool-call scheduler: failure quiescence', () => { }) }) }) + +describe('code-mode native-tool denial through the agent loop', () => { + /** A minimal in-process code runtime for test purposes — never actually runs. */ + class FakeCodeRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'fake' as const + async run(_request: CodeRunRequest): Promise { + return { logs: [] } + } + } + + async function codeModeHarness(adapter: MockAdapter) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape + await ctx.plugin(FakeCodeRuntime as any) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx + } + + it('denies a model-direct native-tool call under code mode: tool body never runs and session records UNKNOWN_TOOL', async () => { + let toolInvoked = false + const tool = defineContentToolFixture({ + name: 'write', + description: 'Write a file.', + parameters: { + file_path: { type: 'string', required: true }, + content: { type: 'string', required: true }, + }, + async execute(_args, _exec) { + toolInvoked = true + return [{ type: 'text', text: 'written' }] + }, + }) + + // Scripted model emits a native tool call under code mode — the wire + // never advertised it, but a non-compliant provider may still emit one. + const adapter = new MockAdapter([ + [ + ...multiCall([{ id: 'call-1', name: 'write', args: { file_path: '/tmp/test', content: 'hello' } }]), + ...textResponse('ok'), + ], + ]) + + const ctx = await codeModeHarness(adapter) + ctx.tools.register(tool) + + const agent = ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'write a file' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + + // The tool body must NOT have executed — the collapse denied the call + // at createExecution, before the body could start. + expect(toolInvoked).toBe(false) + + // The session must record a tool/result with UNKNOWN_TOOL error so the + // transcript faithfully captures that the call was denied. + const sessionEvents = events(agent) + const toolResult = sessionEvents.find(e => e.type === 'tool/result') + expect(toolResult).toBeDefined() + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool/result data uses a loose event payload union + expect((toolResult!.data as any).error).toMatchObject({ + name: 'ToolNotFoundError', + code: 'UNKNOWN_TOOL', + }) + }) +}) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index b121662390..5ab35d7ce0 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/tools/README.md -README.md: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7 -README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165 +README.md: d556130bf924b8dbd7ba4d5afdd8bdfc792be38f +README.zh.md: d7766b432c5a319d214da80e3df438489519be92 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 75d18712a0..d556130bf9 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. @@ -66,7 +66,7 @@ First-party plugin authors can use the `defineTool()` helper (exported from this ```ts import { readFile } from 'node:fs/promises' -import type { Context } from '@deepseek-ai/cordis' +import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8d4ae42596..d7766b432c 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 -- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 +- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 @@ -66,7 +66,7 @@ tools: ```ts import { readFile } from 'node:fs/promises' -import type { Context } from '@deepseek-ai/cordis' +import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 01259b8de2..a10962aba9 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tools */ -import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' +import { Context, Service } from 'cordis' +import z from 'schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' @@ -120,7 +120,7 @@ export type { WebSource, } from './presentation.ts' -declare module '@deepseek-ai/cordis' { +declare module 'cordis' { interface Context { tools: ToolRegistry } @@ -313,6 +313,10 @@ export interface ToolExecutionInput { * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. + * The token also marks the call as a transport sub-dispatch rather than a + * model-direct call: under `mode: 'code'`, only calls WITH a parent may + * execute a native tool name — a model-direct call (no parent) is denied as + * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}. */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ @@ -647,14 +651,13 @@ export interface Config { } /** - * Per-scope filter over the tools a scope INHERITS — the global layer and - * every ancestor layer on its chain. Restrictions intersect, and do not affect - * the scope's own registrations or the reserved Code Mode transport. + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. */ export interface ToolRestriction { - /** Inherited tool names that stay visible; every other inherited one is removed. */ + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] - /** Inherited tool names removed from visibility. */ + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } @@ -670,7 +673,7 @@ interface ToolView { readonly visible: ReadonlyMap /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet - /** Current inherited names a scoped restriction may name; its own are exempt. */ + /** Current global names that a scoped restriction may name. */ readonly restrictableNames: ReadonlySet } @@ -708,7 +711,7 @@ class ToolLayer implements ScopeLayer { && this.mode === undefined } - /** Whether every compiled restriction in this layer admits an inherited tool name. */ + /** Whether every compiled restriction in this layer admits a global tool name. */ admits(name: string): boolean { for (const filter of this.restrictions.values()) { if ((filter.allow !== undefined && !filter.allow.has(name)) @@ -807,6 +810,17 @@ export class ToolRegistry extends Service { if (this.defaultMode !== 'native') { ctx.systemPrompt.section(this.sdkSection()) } + // Under `code` mode, filter out tool-specific guidance sections + // (`tool:*`) that instruct the model to call native tools directly. + // The `tools:sdk` section and SDK types remain — they teach the model + // how to call tools through `run_code`. + if (this.defaultMode === 'code') { + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + result.sections = result.sections.filter(s => !s.name.startsWith('tool:')) + return result + }) + } } /** @@ -913,7 +927,6 @@ export class ToolRegistry extends Service { // keeps one rule instead of a case analysis. if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection()) }.bind(this), 'tools.presentAs()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity return dispose } @@ -1032,7 +1045,7 @@ export class ToolRegistry extends Service { const known = this.view(scope).restrictableNames const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { - throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`) + throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) } return this.layers.effect( this.ctx, @@ -1073,54 +1086,30 @@ export class ToolRegistry extends Service { /** * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies restrictions to the INHERITED surface, then the - * scope's own registrations and the reserved presentation transport; the - * other sets retain the pre-restriction facts needed by restriction and - * prompt-order validation. - * - * A restriction filters what a scope inherits — the global layer and every - * ancestor layer on its chain — and never what its OWN layer registers. - * That exemption is what a per-child capability filter has to keep intact: - * the delegation runtime registers a child's reporting and structured-output - * tools into the child's own layer, and a filter naming the capabilities the - * child may use must not strip the machinery it answers through. - * - * Reading the exempt set as "the global layer" instead of "not mine" held - * only while every model-facing tool sat in the host composition. Once - * presets moved them onto the agent plane they became an ANCESTOR - * contribution, so a child's filter silently stopped constraining anything - * it was given. + * visible map applies global restrictions, scoped shadowing, and the reserved + * presentation transport; the other sets retain the pre-restriction facts + * needed by restriction and prompt-order validation. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) - // Chain-blind on purpose: this is the ONE layer whose registrations the - // scope owns rather than inherits, and it is absent until the scope - // contributes something. - const own = this.layers.peek(scope) - // Inherited surface, nearest ancestor last: a nearer scope's same-name - // entry shadows a farther one, and the global layer is the farthest. - const inherited = new Map(this.layers.global.tools.entries()) - for (const layer of layers) { - if (layer === own) continue - for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition) - } const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() - for (const [name, definition] of inherited) { + for (const [name, definition] of this.layers.global.tools.entries()) { knownNames.add(name) restrictableNames.add(name) // Restrictions intersect across the whole chain: any scope on it may - // mask an inherited name for everything nested inside it. + // mask a global-surface name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // The scope's own registrations last, shadowing an inherited name and - // outside the filter above. - if (own !== undefined) { - for (const [name, definition] of own.tools.entries()) { + // Chain layers second, nearest last: same-name entries REPLACE (shadow) + // the global and farther-scope ones, and scope-local registrations are + // never part of the global filter above. + for (const layer of layers) { + for (const [name, definition] of layer.tools.entries()) { knownNames.add(name) visible.set(name, definition) } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 217875898d..03d2d6b75b 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context } from 'cordis' import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -907,6 +907,25 @@ describe('ToolRegistry', () => { }) }) + it('keeps the normalized content when the final content transform returns undefined', async () => { + const ctx = await setup() + let finalized = 0 + ctx.tools.register({ + ...echoTool, + name: 'identity-finalizer', + finalizeContent() { + finalized += 1 + return undefined + }, + }) + + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} }) + + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: '' }]) + expect(finalized).toBe(1) + }) + it('a block decision can ALSO attach additionalContexts', async () => { const ctx = await setup() ctx.tools.register(echoTool) From 75a09efd519e8862d8c41394d5eebd7d464b048a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 23:33:01 +0800 Subject: [PATCH 02/46] fix(tools): use @deepseek-ai/cordis and @deepseek-ai/schemastery imports --- packages/core/tools/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a10962aba9..b39625705d 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tools */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' From 6f6defd64a7016e889815c28eb1f9837577f1bc9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 00:58:45 +0800 Subject: [PATCH 03/46] fix(tools): collapse code-mode executor to run_code for model-direct calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireSchemas() already advertised only run_code under mode: 'code', but the executor resolved every call through get(), which returns the full visible map plus the reserved transport. A model could name a native tool directly and bypass run_code entirely. Route the execution-path lookups through a new private resolveExecution() that applies the mode collapse at the operation boundary: model-direct calls under 'code' may only name run_code (UNKNOWN_TOOL otherwise), while SDK sub-dispatches (parent token set) keep every visible tool. get()/schemas() public semantics are unchanged. The denial happens at createExecution, before the extensible policy pipeline — pre-execute listeners, approval ask, and guards never observe a call that is deterministically denied. A collapsed call honors the pre-dispatch cancellation contract, routes aborted results through the visible tool's finalizeContent, and captures the finalizer before argument materialization. Under code mode, a system-prompt/assemble listener filters out tool:* guidance sections that told the model to call native tools directly. The tools:sdk section and SDK types remain so programs can still use all tools through run_code. Regenerated docs, catalogs, graphs, scoped events, and re-recorded translation pairs. Fixes #1815 --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/tools.i18n.yaml | 4 ++-- docs/subsystems/tools.md | 19 +++++++++++-------- docs/subsystems/tools.zh.md | 19 +++++++++++-------- .../core/agent-loop/tests/tool-calls.spec.ts | 2 +- packages/core/tools/src/index.ts | 2 +- packages/core/tools/tests/tools.spec.ts | 2 +- 8 files changed, 29 insertions(+), 23 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 70024f4377..ed0b613d41 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: 08c1c63050aefd9e0fcc7c603ad40ec6405bf6c1 +config-catalog.md: f84fdb17f29f9352cda77fc1901beba79a416c2f config-catalog.zh.md: e7cfcff8d69c3a1efbe8426e25415783bc41f864 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 08c1c63050..f84fdb17f2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2507,7 +2507,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:625`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:629`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 79fe385cd9..9aaa64d3ac 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 54d20de3b4d1f2ff6d03a8e89e9356eef403382a -tools.zh.md: f8a35ffa7121438b0623f2a0972f90df2923ac89 +tools.md: 4f0360f45e7ed4f3537bedeb0f370f8eb3472ab9 +tools.zh.md: aa6c67c6379e4a9b9ab7bdc473f6294d063e3649 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 54d20de3b4..4f0360f45e 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -156,14 +156,13 @@ Registration is a trusted same-process contract. The registry borrows the typed ```ts type-equiv /** - * Per-scope filter over the tools a scope INHERITS — the global layer and - * every ancestor layer on its chain. Restrictions intersect, and do not affect - * the scope's own registrations or the reserved Code Mode transport. + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Inherited tool names that stay visible; every other inherited one is removed. */ + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] - /** Inherited tool names removed from visibility. */ + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -198,8 +197,12 @@ interface ToolExecutionInput { /** * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - */ + * the outer `run_code` outcome without receiving its live mutable execution. + * The token also marks the call as a transport sub-dispatch rather than a + * model-direct call: under `mode: 'code'`, only calls WITH a parent may + * execute a native tool name — a model-direct call (no parent) is denied as + * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}. + */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ readonly signal: AbortSignal @@ -568,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:764`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index f8a35ffa71..aa6c67c637 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -156,14 +156,13 @@ type InferArgs = InferProperties ```ts type-equiv /** - * Per-scope filter over the tools a scope INHERITS — the global layer and - * every ancestor layer on its chain. Restrictions intersect, and do not affect - * the scope's own registrations or the reserved Code Mode transport. + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Inherited tool names that stay visible; every other inherited one is removed. */ + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] - /** Inherited tool names removed from visibility. */ + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -198,8 +197,12 @@ interface ToolExecutionInput { /** * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - */ + * the outer `run_code` outcome without receiving its live mutable execution. + * The token also marks the call as a transport sub-dispatch rather than a + * model-direct call: under `mode: 'code'`, only calls WITH a parent may + * execute a native tool name — a model-direct call (no parent) is denied as + * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRegistry.execute}. + */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ readonly signal: AbortSignal @@ -568,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:764`](../../packages/core/tools/src/index.ts) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 10cbf9f056..e02fc592e4 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index b39625705d..124d709c00 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -120,7 +120,7 @@ export type { WebSource, } from './presentation.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 03d2d6b75b..b0b20ff8c1 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' From b558afc373e05c077b073df9dea55a59be869dc1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 01:34:47 +0800 Subject: [PATCH 04/46] fix(tools): use @deepseek-ai/cordis import in README type blocks --- packages/core/tools/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 5ab35d7ce0..8f496c02ec 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/tools/README.md -README.md: d556130bf924b8dbd7ba4d5afdd8bdfc792be38f -README.zh.md: d7766b432c5a319d214da80e3df438489519be92 +README.md: 653720b9a577df630c3b4c23a4615ff640e95292 +README.zh.md: 8c4671bf8d4f10f806fcce1a21044c82ff2d6d7d diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index d556130bf9..653720b9a5 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -66,7 +66,7 @@ First-party plugin authors can use the `defineTool()` helper (exported from this ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d7766b432c..8c4671bf8d 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -66,7 +66,7 @@ tools: ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context From 2aef2d83fa27d46e00a2ce440d0958ee5ecadff5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 02:32:24 +0800 Subject: [PATCH 05/46] fix(tools): remove tool:* prompt filtering per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LegGasai noted that filtering prompt sections by tool:* prefix is a poor heuristic: it conflates section naming convention with presentation semantics and would incorrectly drop tool:structured_output. The executor collapse already enforces the boundary — a model-direct native call is rejected as UNKNOWN_TOOL regardless of what the prompt says, so filtering the prompt adds no security and only risks losing useful guidance. The tool:read/tool:bash/etc sections describe capability usage patterns that apply to both native and code presentations, and keeping them does not reopen the native direct-call path because the executor blocks it. --- .../code-mode-turn/system-prompt.expected.md | 8 +++----- packages/core/tools/src/index.ts | 11 ----------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 3771a70950..94e7ee4afb 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -21,8 +21,6 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. -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. - ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -117,16 +115,16 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ + /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 124d709c00..8a99b58eab 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -810,17 +810,6 @@ export class ToolRegistry extends Service { if (this.defaultMode !== 'native') { ctx.systemPrompt.section(this.sdkSection()) } - // Under `code` mode, filter out tool-specific guidance sections - // (`tool:*`) that instruct the model to call native tools directly. - // The `tools:sdk` section and SDK types remain — they teach the model - // how to call tools through `run_code`. - if (this.defaultMode === 'code') { - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const result = await next() - result.sections = result.sections.filter(s => !s.name.startsWith('tool:')) - return result - }) - } } /** From 24dd48b1331457e046ef35a669116ada9e91073c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 03:01:14 +0800 Subject: [PATCH 06/46] fix(tools): collapse code-mode executor to run_code for model-direct calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wireSchemas() already advertised only run_code under mode: 'code', but the executor resolved every call through get(), which returns the full visible map plus the reserved transport. A model could name a native tool directly and bypass run_code entirely. Route the execution-path lookups through a new private resolveExecution() that applies the mode collapse at the operation boundary: model-direct calls under 'code' may only name run_code (UNKNOWN_TOOL otherwise), while SDK sub-dispatches (parent token set) keep every visible tool. get()/schemas() public semantics are unchanged. The denial happens at createExecution, before the extensible policy pipeline — pre-execute listeners, approval ask, and guards never observe a call that is deterministically denied. A collapsed call honors the pre-dispatch cancellation contract, routes aborted results through the visible tool's finalizeContent, and captures the finalizer before argument materialization. Regenerated docs, catalogs, graphs, scoped events, re-recorded translation pairs, and updated test assertions. Fixes #1815 --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 ++- docs/config-catalog.zh.md | 18 ++- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 2 +- docs/subsystems/tools.zh.md | 2 +- packages/core/tools/src/index.ts | 117 ++++++++++++++---- packages/core/tools/tests/scoped.spec.ts | 8 +- .../tests/subagent-inprocess.spec.ts | 2 +- .../tests/subagent-spawn.spec.ts | 2 +- 10 files changed, 120 insertions(+), 57 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ed0b613d41..ec81187785 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: f84fdb17f29f9352cda77fc1901beba79a416c2f -config-catalog.zh.md: e7cfcff8d69c3a1efbe8426e25415783bc41f864 +config-catalog.md: 6362022dd1b80e17997684574d9dc7f14372e23a +config-catalog.zh.md: 3fd335ee2b1ec6d1526f75ed9667f1d9286faafe diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f84fdb17f2..6362022dd1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2481,16 +2481,14 @@ Requires: `systemPrompt` /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation for agents that declare none of their own. `native` - * (default) sends every visible schema; `code` sends only `run_code` plus a - * generated SDK prompt; `both` sends both forms. Code modes require a - * `ctx.codeRuntime` whose `language` has a registered SDK renderer - * (TypeScript or Python) and fail prompt assembly when it is absent or has - * no renderer. Under `code`, native names in `toolOrder` are invalid. - * - * One agent overrides this for itself with {@link ToolRegistry.presentAs}, - * which is how an agent preset composes a Code Mode agent beside native - * ones in the same process. + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt and collapses the + * executor to the same surface (a model-direct call may only name + * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both` + * sends both forms. Code modes require a `ctx.codeRuntime` whose `language` + * has a registered SDK renderer (TypeScript or Python) and fail prompt + * assembly when it is absent or has no renderer. Under `code`, native names + * in `toolOrder` are invalid. */ mode?: ToolPresentationMode /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e7cfcff8d6..3fd335ee2b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2482,16 +2482,14 @@ export interface Config { /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation for agents that declare none of their own. `native` - * (default) sends every visible schema; `code` sends only `run_code` plus a - * generated SDK prompt; `both` sends both forms. Code modes require a - * `ctx.codeRuntime` whose `language` has a registered SDK renderer - * (TypeScript or Python) and fail prompt assembly when it is absent or has - * no renderer. Under `code`, native names in `toolOrder` are invalid. - * - * One agent overrides this for itself with {@link ToolRegistry.presentAs}, - * which is how an agent preset composes a Code Mode agent beside native - * ones in the same process. + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt and collapses the + * executor to the same surface (a model-direct call may only name + * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both` + * sends both forms. Code modes require a `ctx.codeRuntime` whose `language` + * has a registered SDK renderer (TypeScript or Python) and fail prompt + * assembly when it is absent or has no renderer. Under `code`, native names + * in `toolOrder` are invalid. */ mode?: ToolPresentationMode /** diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 9aaa64d3ac..3974e0217f 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 4f0360f45e7ed4f3537bedeb0f370f8eb3472ab9 -tools.zh.md: aa6c67c6379e4a9b9ab7bdc473f6294d063e3649 +tools.md: fff43d587428ca4747cfd19ac5fccca08b4e048e +tools.zh.md: 4705edf81e56c81b237a69caa1c012ba12d67716 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 4f0360f45e..fff43d5874 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -571,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:764`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:762`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index aa6c67c637..4705edf81e 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -571,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:764`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:762`](../../packages/core/tools/src/index.ts) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 8a99b58eab..12d84bc66c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -628,16 +628,14 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation for agents that declare none of their own. `native` - * (default) sends every visible schema; `code` sends only `run_code` plus a - * generated SDK prompt; `both` sends both forms. Code modes require a - * `ctx.codeRuntime` whose `language` has a registered SDK renderer - * (TypeScript or Python) and fail prompt assembly when it is absent or has - * no renderer. Under `code`, native names in `toolOrder` are invalid. - * - * One agent overrides this for itself with {@link ToolRegistry.presentAs}, - * which is how an agent preset composes a Code Mode agent beside native - * ones in the same process. + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt and collapses the + * executor to the same surface (a model-direct call may only name + * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both` + * sends both forms. Code modes require a `ctx.codeRuntime` whose `language` + * has a registered SDK renderer (TypeScript or Python) and fail prompt + * assembly when it is absent or has no renderer. Under `code`, native names + * in `toolOrder` are invalid. */ mode?: ToolPresentationMode /** @@ -916,6 +914,7 @@ export class ToolRegistry extends Service { // keeps one rule instead of a case analysis. if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection()) }.bind(this), 'tools.presentAs()') + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity return dispose } @@ -1075,30 +1074,54 @@ export class ToolRegistry extends Service { /** * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies global restrictions, scoped shadowing, and the reserved - * presentation transport; the other sets retain the pre-restriction facts - * needed by restriction and prompt-order validation. + * visible map applies restrictions to the INHERITED surface, then the + * scope's own registrations and the reserved presentation transport; the + * other sets retain the pre-restriction facts needed by restriction and + * prompt-order validation. + * + * A restriction filters what a scope inherits — the global layer and every + * ancestor layer on its chain — and never what its OWN layer registers. + * That exemption is what a per-child capability filter has to keep intact: + * the delegation runtime registers a child's reporting and structured-output + * tools into the child's own layer, and a filter naming the capabilities the + * child may use must not strip the machinery it answers through. + * + * Reading the exempt set as "the global layer" instead of "not mine" held + * only while every model-facing tool sat in the host composition. Once + * presets moved them onto the agent plane they became an ANCESTOR + * contribution, so a child's filter silently stopped constraining anything + * it was given. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) + // Chain-blind on purpose: this is the ONE layer whose registrations the + // scope owns rather than inherits, and it is absent until the scope + // contributes something. + const own = this.layers.peek(scope) + // Inherited surface, nearest ancestor last: a nearer scope's same-name + // entry shadows a farther one, and the global layer is the farthest. + const inherited = new Map(this.layers.global.tools.entries()) + for (const layer of layers) { + if (layer === own) continue + for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition) + } const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() - for (const [name, definition] of this.layers.global.tools.entries()) { + for (const [name, definition] of inherited) { knownNames.add(name) restrictableNames.add(name) // Restrictions intersect across the whole chain: any scope on it may - // mask a global-surface name for everything nested inside it. + // mask an inherited name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // Chain layers second, nearest last: same-name entries REPLACE (shadow) - // the global and farther-scope ones, and scope-local registrations are - // never part of the global filter above. - for (const layer of layers) { - for (const [name, definition] of layer.tools.entries()) { + // The scope's own registrations last, shadowing an inherited name and + // outside the filter above. + if (own !== undefined) { + for (const [name, definition] of own.tools.entries()) { knownNames.add(name) visible.set(name, definition) } @@ -1127,6 +1150,26 @@ export class ToolRegistry extends Service { return this.view(scope).visible.get(name) } + /** + * Resolve the definition that MAY EXECUTE for a call, applying the mode + * collapse at the operation boundary that owns it. The registry view + * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `code` + * may only name the reserved `run_code` transport, while a nested + * sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool + * it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL` + * through the executor, matching an absent definition. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. + * @returns the definition that may run, or undefined when the call must be rejected. + */ + private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { + const tool = this.get(name, scope) + if (tool === undefined) return undefined + if (this.collapses(name, nested)) return undefined + return tool + } + /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. @@ -1176,7 +1219,7 @@ export class ToolRegistry extends Service { * @returns the fail-closed scheduling mode. */ executionMode(exec: ToolExecutionInput): ToolExecutionMode { - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } try { const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) @@ -1270,6 +1313,19 @@ export class ToolRegistry extends Service { concludingExecutions.add(this as unknown as ToolExecution) }, } + // Capture the finalizer BEFORE argument materialization: the + // `finalizeContent` contract snapshots the callback when the call starts, + // and an arguments getter can replace or clear the registered callback + // during `snapshotJsonValue`. The collapse only decides whether the + // CAPTURED callback is retained: the pre-dispatch abort path keeps it + // (the cancellation contract routes aborted results through it — a getter + // that aborts mid-materialization before an invalid-args failure lands in + // the same retained path), while the `UNKNOWN_TOOL` denial and the + // invalid-args failure of a NON-ABORTED collapsed call drop it (the call + // could never execute). + const capturedFinalizer = visible?.finalizeContent?.bind(visible) + const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined => + collapsed && !signal.aborted ? undefined : capturedFinalizer try { const detached = snapshotJsonValue(exec.arguments) if (detached === undefined) { @@ -1282,10 +1338,21 @@ export class ToolRegistry extends Service { callerSignal: signal, bodyInvoked: false, }) + if (collapsed) { + // The collapse denies the call before the policy pipeline, but a + // pre-dispatch abort still keeps the established cancellation + // contract: `prepare`'s caller-cancellation check is skipped for + // final-results, so honor the abort here instead of surfacing + // `UNKNOWN_TOOL` on an already-cancelled call. + if (signal.aborted) { + return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } + } + return { kind: 'final-result', exec: execution, result: toolErrorResult(new ToolNotFoundError(name)) } + } return { kind: 'ready', exec: execution } } catch (error: unknown) { const execution: MutableToolRunContext = { ...base, arguments: undefined } - this.contentFinalizers.set(execution, finalizeContent) + this.contentFinalizers.set(execution, finalizerFor()) return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } } } @@ -1383,7 +1450,7 @@ export class ToolRegistry extends Service { } exec.signal = signal try { - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (!tool) throw new ToolNotFoundError(exec.name) state.bodyInvoked = true const returned = await tool.execute(exec.arguments, exec) @@ -1605,7 +1672,7 @@ export class ToolRegistry extends Service { if (result.isError) { throw new TypeError('tools/post-execute cannot replace the value of a failed result') } - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (tool === undefined) throw new ToolNotFoundError(exec.name) const replaced = this.createSuccessResult(exec, tool, decision.value) return this.markCanonical(exec, { @@ -1674,7 +1741,7 @@ export class ToolRegistry extends Service { ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, }) } - const tool = this.get(exec.name, exec.agent) + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) if (tool === undefined) throw new ToolNotFoundError(exec.name) const normalized = this.createSuccessResult(exec, tool, result.value) return this.markCanonical(exec, { diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 6f03e0aef4..cc868f8f85 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -190,14 +190,14 @@ describe('restrict()', () => { expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) // A scope's own registration is exempt from its own filter, so naming it // is a caller error rather than a silent no-op. - expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/) - expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s) - expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/) + expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/) + expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall".*known global tools: real/s) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/) const emptyCtx = await mount() const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) - .toThrow(/Restrictable tools: \(none\)/) + .toThrow(/known global tools: \(none\)/) }) }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index bc25a79c23..f6247d9a6a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -343,7 +343,7 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), toolFilter: { deny: ['unknown-tool'] }, - }, {})).rejects.toThrow('unknown inherited tool') + }, {})).rejects.toThrow('unknown global tool') expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index fdef892d7f..e3a83942ea 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - })).rejects.toThrow(/unknown inherited tool "no_such_tool"/) + })).rejects.toThrow(/unknown global tool "no_such_tool"/) expect(ctx.agents.list().length).toBe(before) }) }) From 1e785138062869207c6ac80e2b912d97cec5916f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 03:06:01 +0800 Subject: [PATCH 07/46] fix(tools): add collapses() method and fix createExecution collapse logic --- packages/core/tools/src/index.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 12d84bc66c..469c48313c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1250,6 +1250,19 @@ export class ToolRegistry extends Service { } } + /** + * Whether the `code` mode collapse denies a model-direct call: only the + * reserved `run_code` transport may be named. Nested sub-dispatches (a + * `parent` token set) bypass the collapse. One home for the + * security-relevant predicate, shared by {@link resolveExecution} and + * {@link createExecution} so the two can never drift apart. + * @param name - the tool name as registered. + * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. + */ + private collapses(name: string, nested: boolean): boolean { + return !nested && this.defaultMode === 'code' && name !== RUN_CODE_NAME + } + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1295,8 +1308,15 @@ export class ToolRegistry extends Service { const agent = exec.agent const parent = exec.parent const signal = exec.signal - const definition = this.get(name, agent) - const finalizeContent = definition?.finalizeContent?.bind(definition) + // Distinguish a mode-collapsed call (visible in the scope, denied only by + // the `code` collapse) from a genuinely unknown tool. A collapsed call is + // deterministically denied, so it terminates BEFORE the extensible policy + // pipeline: pre-execute listeners, approval `ask`, and guards must never + // observe — or worse, approve — a call that can only fail. An unknown tool + // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy + // listeners still see every name that reaches the registry. + const visible = this.get(name, agent) + const collapsed = visible !== undefined && this.collapses(name, parent !== undefined) const concludingExecutions = this.concludingExecutions const base = { token, @@ -1333,7 +1353,7 @@ export class ToolRegistry extends Service { } const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } this.deferredContexts.set(execution, deferredContexts) - this.contentFinalizers.set(execution, finalizeContent) + this.contentFinalizers.set(execution, finalizerFor()) this.cancellationStates.set(execution, { callerSignal: signal, bodyInvoked: false, From 846969900a0b83c03686d3bb2b9a8cf739dfdcc7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 03:32:00 +0800 Subject: [PATCH 08/46] ci: retrigger From 6d0a7c12e12dcd160ec6a5d6e5be2a012a49ebea Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 11:10:24 +0800 Subject: [PATCH 09/46] test(tools): add coverage for collapsed model-direct call under code mode Add two executor-level tests covering the previously uncovered branches in createExecution: - collapsed call (non-aborted signal) returns UNKNOWN_TOOL - collapsed call (pre-aborted signal) returns ABORTED_BEFORE_DISPATCH --- packages/core/tools/tests/code-mode.spec.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 596f3f630a..0b9eb16ef1 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1561,6 +1561,40 @@ describe('the run_code dispatch bridge', () => { const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) + it('denies a model-direct native-tool call under code mode as UNKNOWN_TOOL', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + const registry = new ToolRegistry(ctx, { mode: 'code' }) + registerEcho(ctx, 'write') + const result = await registry.execute({ + signal: testToolSignal, + callId: CallId('call-1'), + name: 'write', + arguments: { text: 'hello' }, + }) + expect(result.isError).toBe(true) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- error is a union type + expect((result.error as any).code).toBe('UNKNOWN_TOOL') + }) + + it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + const registry = new ToolRegistry(ctx, { mode: 'code' }) + registerEcho(ctx, 'write') + const aborted = new AbortController() + aborted.abort() + const result = await registry.execute({ + signal: aborted.signal, + callId: CallId('call-1'), + name: 'write', + arguments: { text: 'hello' }, + }) + expect(result.isError).toBe(true) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- error is a union type + expect((result.error as any).code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + }) + }) /** From 610dc74ea82001ac20e48db799dd3ecd047199e0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 14:49:01 +0800 Subject: [PATCH 10/46] test(tools): fix error code assertions for collapsed call tests The error code lives on ToolFailure.info.code, not ToolFailure.code. --- packages/core/tools/tests/code-mode.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 0b9eb16ef1..c5a1a8995a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1574,7 +1574,7 @@ describe('the run_code dispatch bridge', () => { }) expect(result.isError).toBe(true) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- error is a union type - expect((result.error as any).code).toBe('UNKNOWN_TOOL') + expect((result.error as any).info.code).toBe('UNKNOWN_TOOL') }) it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => { @@ -1592,7 +1592,7 @@ describe('the run_code dispatch bridge', () => { }) expect(result.isError).toBe(true) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- error is a union type - expect((result.error as any).code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + expect((result.error as any).info.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) }) }) From 47f108bf50c17d1b7074d12393b613d7bac39594 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 15:36:10 +0800 Subject: [PATCH 11/46] test(tools): use type-safe error assertions instead of any casts oxlint's no-unsafe-member-access rejects member access through an any cast; the error info is reachable through the declared optional chain. --- packages/core/agent-loop/tests/tool-calls.spec.ts | 3 +-- packages/core/tools/tests/code-mode.spec.ts | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index e02fc592e4..f4ea11aeff 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -754,8 +754,7 @@ describe('code-mode native-tool denial through the agent loop', () => { const sessionEvents = events(agent) const toolResult = sessionEvents.find(e => e.type === 'tool/result') expect(toolResult).toBeDefined() - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool/result data uses a loose event payload union - expect((toolResult!.data as any).error).toMatchObject({ + expect(toolResult!.data.error).toMatchObject({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL', }) diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index c5a1a8995a..a0248f3a10 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1573,8 +1573,7 @@ describe('the run_code dispatch bridge', () => { arguments: { text: 'hello' }, }) expect(result.isError).toBe(true) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- error is a union type - expect((result.error as any).info.code).toBe('UNKNOWN_TOOL') + expect(result.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) }) it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => { @@ -1591,8 +1590,7 @@ describe('the run_code dispatch bridge', () => { arguments: { text: 'hello' }, }) expect(result.isError).toBe(true) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- error is a union type - expect((result.error as any).info.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + expect(result.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) }) }) From 428aec44e6db55ab35354b69c83b4a6e7a533af4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 16:41:16 +0800 Subject: [PATCH 12/46] ci: retrigger windows native runner --- .../snapshots/code-mode-turn/system-prompt.expected.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 94e7ee4afb..3771a70950 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -21,6 +21,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +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. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -115,16 +117,16 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */ + /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; From 5d2c943d388bfc3c843666573415c6197a6e4dc2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 22:44:34 +0800 Subject: [PATCH 13/46] fix(tools): state the code-mode collapse in the prompt and the denial The executor collapse landed without telling the model it exists. Every tool contributes its own guidance section naming its tool, none of them qualify how that tool is reached, and they all render before the SDK (orders 100-199 against SDK_SECTION_ORDER 150), so the prompt said "Use the read tool" eleven times and never said only run_code is callable. A real session shows the consequence: the model emitted a native call, read `unknown tool "read"` for a tool the same prompt declares, and concluded the deployment was inconsistent rather than routing through run_code. The registry now contributes `tools:code-only` at order 99 -- ahead of the guidance band -- stating the rule, registered wherever `tools:sdk` is and rendering empty outside an effective `code`. `both` renders it empty because its native calls do execute, which is also why both-mode-turn no longer shares code-mode-turn's expected prompt. The denial itself now names the route back, since a bare UNKNOWN_TOOL for a declared tool is what misled the model. --- .../2026-08-07-code-mode-executor-collapse.md | 5 +- ...26-08-07-code-mode-executor-collapse.zh.md | 5 +- examples/acp-agent/tests/acp.snapshot.ts | 4 +- .../both-mode-turn/system-prompt.expected.md | 439 ++++++++++++++++++ .../code-mode-turn/system-prompt.expected.md | 2 + .../core/agent-tool-mode/README.i18n.yaml | 4 +- packages/core/agent-tool-mode/README.md | 2 +- packages/core/agent-tool-mode/README.zh.md | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 8 +- packages/core/tools/README.zh.md | 8 +- packages/core/tools/src/index.ts | 82 +++- packages/core/tools/tests/code-mode.spec.ts | 31 ++ 13 files changed, 571 insertions(+), 25 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md index 817da95678..3b64675f90 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md @@ -12,7 +12,7 @@ The package contract names this exact anti-pattern: schema omission is not enfor ## Decision -`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. A model-direct call (`nested = false`) under `code` may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. +`ToolRegistry` resolves callable definitions through a new private `resolveExecution(name, scope, nested)` that applies the mode collapse at the operation boundary that owns it. When `modeFor(scope)` resolves to `code`, a model-direct call (`nested = false`) may only name the reserved `run_code` transport; every native name resolves to `undefined` and surfaces as the executor's existing `UNKNOWN_TOOL` error, whose message names the route back through `run_code` because the name IS declared to this model (an already-aborted caller signal keeps the cancellation contract: `ABORTED_BEFORE_DISPATCH`, with the visible tool's finalizer applied). The effective scope mode includes declarations inherited from an agent preset, so its wire schema and execution permissions remain aligned. A collapsed call terminates at `createExecution` — the first stage of `prepare` — BEFORE the extensible policy pipeline, so `tools/pre-execute` listeners, approval `ask`, and guards never observe a call that is deterministically denied; a human is never prompted to approve it. A nested sub-dispatch (`nested = true` — a `parent` token set, which only the `run_code` SDK binding sets in production code) may call any visible tool, so programs keep every binding the generated SDK declared. Four execution-path lookups — `executionMode`, `dispatchToolBody`, `postExecute`, `normalizeDispatchResult` — go through `resolveExecution`. `createExecution` applies the same collapse via the shared `collapses(name, nested)` predicate so it can distinguish a collapsed call from a genuinely unknown name before the policy pipeline. The public registry view (`get`) and SDK projection (`schemas`) keep their semantics: presentation, inspection, and binding enumeration still see the full visible set. The wire (`wireSchemas`) and the executor now agree. A collapsed call with non-JSON-serializable arguments reports the parameter `TypeError` (the invalid-args contract), not `UNKNOWN_TOOL` — the body still never runs and policy still does not. @@ -41,5 +41,6 @@ No provider guarantees interception of unadvertised names; the reported session - `mode: 'code'` now enforces what it announces: a model-direct native call becomes `UNKNOWN_TOOL`, which the model can correct by routing through `run_code` (a pre-aborted call still resolves `ABORTED_BEFORE_DISPATCH`, per the cancellation contract). - `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). - A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. -- Under `code` mode, the imperative native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) are filtered from the system prompt by a `system-prompt/assemble` listener so the model is never told to call a tool it cannot reach directly. The `tools:sdk` section (TypeScript bindings) remains, so programs can still use every tool through `run_code`. +- Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls. +- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of the 100-199 guidance band. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt. - Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md index 7b4a1b8adc..51b35ad44b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`ToolRegistry` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。`code` 模式下,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 +`ToolRegistry` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。当 `modeFor(scope)` 解析为 `code` 时,模型直呼(`nested = false`)只允许命名保留的 `run_code` 传输工具;任何原生名字都解析为 `undefined`,并以执行器既有的 `UNKNOWN_TOOL` 错误呈现,其消息会指出改走 `run_code` 的正确路径——因为这个名字对当前模型而言是**已声明过**的(已中止的调用方 signal 保留取消契约:`ABORTED_BEFORE_DISPATCH`,并应用可见工具的 finalizer)。有效的 scope 模式包括从 agent preset 继承的声明,因此其 wire schema 与执行权限保持一致。被塌缩的调用在 `createExecution`(`prepare` 的第一阶段)即终止——在可扩展策略流水线之前,因此 `tools/pre-execute` 监听器、approval `ask` 与 guard 永远不会观察到一个注定被拒绝的调用,人类也不会被提示去批准它。嵌套子调用(`nested = true`——即设置了 `parent` token,生产代码中只有 `run_code` SDK 绑定会设置)可以调用任意可见工具,因此程序保留生成 SDK 声明的全部绑定。 执行链路的四处查表——`executionMode`、`dispatchToolBody`、`postExecute`、`normalizeDispatchResult`——改走 `resolveExecution`。`createExecution` 通过共享的 `collapses(name, nested)` 谓词应用同一塌缩,以便在策略流水线之前区分被塌缩的调用与真正未知的名字。公共注册表视图(`get`)与 SDK 投影(`schemas`)语义不变:展示、检查与绑定枚举仍看到完整可见集合。通告(`wireSchemas`)与执行器现在一致。带非 JSON 可序列化参数的塌缩调用报告参数 `TypeError`(invalid-args 契约),而非 `UNKNOWN_TOOL`——函数体仍不会运行,策略也不会执行。 @@ -41,5 +41,6 @@ guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装 - `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 - `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 - 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 -- 在 `code` 模式下,命令式原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)现在通过 `system-prompt/assemble` 监听器从系统提示词中过滤,模型不会再被告知去直接调用它无法触达的工具。`tools:sdk` 段(TypeScript 绑定)保留,程序仍可通过 `run_code` 使用所有工具。 +- 原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)保留在系统提示词中,因为它们同时描述了通过生成 SDK 及原生函数调用可用的能力,其中若干段还承载着任何单个工具描述都装不下的跨工具路由策略(`read` 优先于 `bash cat`、默认 fs-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 +- 提示词会**声明**这条塌缩,位于排在 100–199 指导段之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 - 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 0cddc9a504..39c5209202 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -542,13 +542,15 @@ const SCENARIOS: Scenario[] = [ toolSchemasSource: 'code-mode-turn', configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG, }, + // `both` owns its own expected prompt rather than sharing code-mode-turn's: + // the two modes agree on every section except the run_code-only rule, which + // `both` must NOT state because its native calls do execute. { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', - systemPromptSource: 'code-mode-turn', configPath: BOTH_MODE_CONFIG, }, // Machine permission scenarios use an explicit deployment policy; there is diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md new file mode 100644 index 0000000000..94e7ee4afb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -0,0 +1,439 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use 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. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools: + +```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + bash: { + /** The bash command to execute. */ + command: string; + /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ + description: string; + /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ + timeoutMs?: number; + /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ + workdir?: string; + /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */ + run_in_background?: boolean; + /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ + justification?: string; + } & Record; + /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ + create_goal: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer limit on automatic continuation rounds. */ + max_goal_rounds?: number; + } & Record; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + } & Record; + /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ + get_goal: Record; + /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */ + interrupt_agent: { + /** The agent id of the running agent to interrupt. */ + agent_id: string; + } & Record; + /** List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */ + list_agents: { + /** children (default) lists direct children only; descendants walks the complete tree below you. */ + scope?: "children" | "descendants"; + } & Record; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + } & Record; + /** Read a UTF-8 text file and return line-numbered content. */ + read: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + } & Record; + /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */ + send_message: { + /** The subagent id returned when the background subagent was started. */ + subagent_id: string; + /** The message to deliver to the subagent. */ + message: string; + } & Record; + /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ + skill: { + /** The exact skill name from the available skills list. */ + name: string; + } & Record; + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */ + subagent: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ + prompt: string; + /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */ + run_in_background?: boolean; + } & Record; + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + subagent_fork: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ + prompt: string; + } & Record; + /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ + task_kill: { + /** Task id returned by the tool that started the background work. */ + task_id: string; + /** Optional short reason, recorded in the log and forwarded to the task. */ + reason?: string; + } & Record; + /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ + task_list: Record; + /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ + task_output: { + /** Task id returned by the tool that started the background work. */ + task_id: string; + /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ + wait?: boolean; + /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ + timeout_ms?: number; + } & Record; + /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ + todo_write: { + /** The COMPLETE task list, replacing any previous list. */ + todos: ({ + /** What the task is — a short imperative line. */ + content: string; + /** pending (not started) | in_progress (now) | completed (done). */ + status: "pending" | "in_progress" | "completed"; + })[]; + } & Record; + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + update_goal: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; + } & Record; + /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ + workflow: { + /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ + script: string; + /** The workflow identity block (plain JSON — never code). */ + meta: { + /** Short kebab-case workflow name. */ + name: string; + /** One-line description of what the workflow does. */ + description: string; + /** Optional guidance on when this workflow applies. */ + whenToUse?: string; + /** Optional phase declarations matched by phase() calls. */ + phases?: ({ + /** The phase title phase() calls match by exact string. */ + title: string; + /** Optional one-line description of the phase. */ + detail?: string; + /** Optional provider override this phase is expected to use. */ + provider?: string; + /** Optional model override this phase is expected to use. */ + model?: string; + } & Record)[]; + } & Record; + /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ + args?: Record; + } & Record; + /** Create or fully replace a UTF-8 text file. */ + write: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + } & Record; +} + +interface ToolOutputMap { + bash: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + aborted: boolean; + timeoutMs: number; + stdout: { + text: string; + truncated: boolean; + spillPath?: string; + }; + stderr: { + text: string; + truncated: boolean; + spillPath?: string; + }; + sandbox?: { + mode: string; + denied: boolean; + enforcement?: string; + runnerFailed?: boolean; + }; + }; + create_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + edit: { + path: string; + before: string; + after: string; + }; + get_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + interrupt_agent: { + accepted: boolean; + }; + list_agents: ({ + kind: "child"; + id: string; + label: string; + status: "running" | "idle" | "ready"; + parent?: string; + depth?: number; + } | { + kind: "diagnostic"; + id: string; + reason: "corrupt" | "unsupported" | "unavailable"; + parent?: string; + depth?: number; + })[]; + ralph: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + read: { + path: string; + offset: number; + lines: { + number: number; + text: string; + }[]; + totalLines: number; + }; + send_message: { + messageId: string; + }; + skill: { + name: string; + provider: string; + resourceBase?: { + kind: "directory"; + path: string; + } | { + kind: "url"; + url: string; + } | { + kind: "opaque"; + description: string; + }; + content: string; + }; + subagent: { + kind: "background"; + taskId: string; + } | { + kind: "continuable"; + subagentId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + subagent_fork: { + kind: "background"; + taskId: string; + } | { + kind: "continuable"; + subagentId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + task_kill: { + outcome: "cancellation-requested" | "already-finished"; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + task_list: ({ + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + })[]; + task_output: { + text: string; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + todo_write: { + todos: ({ + content: string; + status: "pending" | "in_progress" | "completed"; + })[]; + counts: { + pending: number; + inProgress: number; + completed: number; + }; + }; + update_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + workflow: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + write: { + path: string; + operation: "create" | "update"; + before: string | null; + after: string; + }; +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: "ToolCallError"; + readonly toolName: ToolName; +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => Promise; +} +``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 3771a70950..f3994dc95b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +`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. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. diff --git a/packages/core/agent-tool-mode/README.i18n.yaml b/packages/core/agent-tool-mode/README.i18n.yaml index 0799e0e547..b5cc383841 100644 --- a/packages/core/agent-tool-mode/README.i18n.yaml +++ b/packages/core/agent-tool-mode/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-tool-mode/README.md -README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2 -README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce +README.md: f5f2df21285411dbb3b8c5cac18cc3ab0dc8a22b +README.zh.md: 84c2ff009540c2637e01875b1171c39afa6ae2f3 diff --git a/packages/core/agent-tool-mode/README.md b/packages/core/agent-tool-mode/README.md index 0ef7f32c08..f5f2df2128 100644 --- a/packages/core/agent-tool-mode/README.md +++ b/packages/core/agent-tool-mode/README.md @@ -20,7 +20,7 @@ One agent declares one presentation. A second declaration in the same compositio ## Model Experience -Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section and the rule that only `run_code` may be called directly, `native` presents every tool schema. The selection also decides what may EXECUTE: under `code` the registry resolves a model-direct call naming any other tool to `UNKNOWN_TOOL`, so this row is what keeps the announced surface and the callable surface the same for every agent it covers ([executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)). #### KV Cache effect diff --git a/packages/core/agent-tool-mode/README.zh.md b/packages/core/agent-tool-mode/README.zh.md index 974fc4ed57..84c2ff0095 100644 --- a/packages/core/agent-tool-mode/README.zh.md +++ b/packages/core/agent-tool-mode/README.zh.md @@ -20,7 +20,7 @@ preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs( ## Model Experience -Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. +间接生效,取决于它在 `dsh-tools` 中选择的投影:`code` 呈现 `run_code`、一份生成的 SDK 段,以及「只有 `run_code` 可被直接调用」这条规则,`native` 呈现每个工具的 schema。该选择同时决定了**什么可以执行**:在 `code` 下,注册表会把模型直呼其他任何工具名解析为 `UNKNOWN_TOOL`,因此这一行正是让「通告面」与「可调用面」对每个被它覆盖的 agent 保持一致的东西([执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md))。 #### KV Cache effect diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 8f496c02ec..5841f76969 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/tools/README.md -README.md: 653720b9a577df630c3b4c23a4615ff640e95292 -README.zh.md: 8c4671bf8d4f10f806fcce1a21044c82ff2d6d7d +README.md: 44eb25b79436a75f08406102fc1e3734e59b1001 +README.zh.md: 35142d8186b21b2930ccc40386bed8cc677d77c3 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 653720b9a5..44eb25b794 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -13,7 +13,7 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport, the generated `tools:sdk` section, and the `tools:code-only` rule stating that only `run_code` may be called directly — which the executor then enforces, resolving a model-direct call naming any other tool to `UNKNOWN_TOOL` before policy runs; `both` contributes both forms and states no such rule, because its native calls do execute. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API @@ -115,7 +115,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. + +Under `code` — not `both` — the transport is also the only entry the model may use: a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` at execution creation, before `tools/pre-execute`, approval `ask`, and guards, so nothing observes or approves a call that can only fail. The denial names the route back (`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`), because the same prompt declares that tool and a bare `unknown tool` reads as a broken deployment. SDK sub-dispatches carry the outer execution's `parent` token and are exempt, so programs keep every binding the SDK declared. See the [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md), the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `:code:`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. @@ -146,7 +148,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. Under `code` the prompt also carries the `tools:code-only` rule, ordered ahead of the per-tool guidance band so the model reads which tools it may call before it reads what each one is for; `both` renders it empty. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). ##### Code Mode SDK instructions diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8c4671bf8d..35142d8186 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -13,7 +13,7 @@ tools: mode: native # native (default) | code | both ``` -`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输、生成的 `tools:sdk` 段,以及声明「只有 `run_code` 可被直接调用」的 `tools:code-only` 规则——执行器随后强制该规则,模型直呼其他任何工具名都会在策略运行之前解析为 `UNKNOWN_TOOL`;`both` 同时贡献两种形式,且不声明该规则,因为它的原生调用确实会执行。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API @@ -115,7 +115,9 @@ ctx.tools.register(defineTool({ ### Code Mode -在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 +在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。 + +在 `code`(而非 `both`)下,该传输同时也是模型唯一可用的入口:模型直呼其他任何可见工具名,都会在创建执行时、早于 `tools/pre-execute`、审批 `ask` 和 guards 解析为 `UNKNOWN_TOOL`,因此没有任何一方会观察或批准一个注定失败的调用。拒绝信息会给出正确路径(`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`),因为同一份提示词刚刚声明过那个工具,只说 `unknown tool` 会被读成部署损坏。SDK 子分发携带外层执行的 `parent` token,不受此限制,因此程序保留 SDK 声明的全部绑定。参见[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)、[Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 - **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 @@ -146,7 +148,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。在 `code` 下,提示词还会带上 `tools:code-only` 规则,其顺序排在逐工具指导段之前,让模型先读到「可以调用哪些工具」再读「每个工具做什么」;`both` 下它渲染为空。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 469c48313c..492112405b 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -43,6 +43,20 @@ import { renderToolsSdkPy } from './py-types.ts' * with its zh pair, plus this package's own README pair and the * {@link Config.mode} JSDoc. */ +/** + * Prompt order of the `code` collapse statement: after the persona and before + * the 100-199 per-tool guidance band, so the model reads which tools it may + * call before it reads what each one is for. + */ +const COLLAPSE_SECTION_ORDER = 99 + +/** + * The model-facing statement of the `code` collapse. Names the consequence + * (the call fails) and the route (inside the program), because a rule the + * model can only discover by being denied is one it corrects too late. + */ +const CODE_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` 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.` + const SDK_RENDERERS: Record string> = { typescript: renderToolsSdk, python: renderToolsSdkPy, @@ -478,8 +492,19 @@ export interface ToolFailure { * distinguish it from a tool body's own error. */ export class ToolNotFoundError extends HarnessError { - constructor(toolName: string) { - super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL') + /** + * @param toolName - the name the caller asked for. + * @param reachableFrom - how the model reaches this tool instead, when the + * name IS visible and only the presentation denies calling it directly. + * Omitted for a name that is registered nowhere. + */ + constructor(toolName: string, reachableFrom?: string) { + super( + reachableFrom === undefined + ? `unknown tool "${toolName}"` + : `unknown tool "${toolName}": ${reachableFrom}`, + 'UNKNOWN_TOOL', + ) this.name = 'ToolNotFoundError' } } @@ -806,10 +831,37 @@ export class ToolRegistry extends Service { this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.defaultMode !== 'native') { + ctx.systemPrompt.section(this.collapseSection()) ctx.systemPrompt.section(this.sdkSection()) } } + /** + * The prompt statement of the `code` executor collapse, registered wherever + * {@link sdkSection} is and rendering empty outside an effective `code`. + * + * Every tool contributes its own guidance section naming its tool, none of + * them qualify how that tool is reached, and they all render before the SDK + * (orders 100-199 against {@link SDK_SECTION_ORDER}). Without this the model + * reads a catalog of tools it is told to use and no statement that only + * `run_code` may be called, so it emits a native call, receives + * `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes the + * deployment is inconsistent. {@link COLLAPSE_SECTION_ORDER} places the rule + * before that guidance rather than after it. + * + * `both` renders empty: native calls do execute there, so the rule is false. + * @returns the section registration. + */ + private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + return { + name: 'tools:code-only', + order: COLLAPSE_SECTION_ORDER, + // The SAME predicate the executor denies by, so the prompt cannot state + // a rule the registry does not enforce (see `collapses`). + text: context => this.modeFor(context.scope) === 'code' ? CODE_ONLY_INSTRUCTION : '', + } + } + /** * The generated-SDK prompt section, registered globally by a code-mode * deployment and per scope by {@link presentAs}. @@ -908,11 +960,14 @@ export class ToolRegistry extends Service { }, { label: 'tools.presentAs()' }, ) - // The SDK section is per scope for the same reason the mode is. Under a - // deployment that already defaults to a code mode this shadows the - // global registration with an identical body, which costs nothing and - // keeps one rule instead of a case analysis. - if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection()) + // The SDK and collapse sections are per scope for the same reason the + // mode is. Under a deployment that already defaults to a code mode this + // shadows the global registration with an identical body, which costs + // nothing and keeps one rule instead of a case analysis. + if (mode !== 'native') { + yield ctx.systemPrompt.section(this.collapseSection()) + yield ctx.systemPrompt.section(this.sdkSection()) + } }.bind(this), 'tools.presentAs()') // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity return dispose @@ -1367,7 +1422,18 @@ export class ToolRegistry extends Service { if (signal.aborted) { return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } } - return { kind: 'final-result', exec: execution, result: toolErrorResult(new ToolNotFoundError(name)) } + // The name IS visible here, so the denial carries the route the model + // must take instead. Without it the model reads a bare `unknown tool` + // for a tool the prompt just declared and concludes the deployment is + // broken rather than correcting itself. + return { + kind: 'final-result', + exec: execution, + result: toolErrorResult(new ToolNotFoundError( + name, + `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`, + )), + } } return { kind: 'ready', exec: execution } } catch (error: unknown) { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index a0248f3a10..eb6d1b22e0 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -135,6 +135,32 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).not.toContain('run_code:') }) + it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + registerEcho(ctx) + // Stand in for a real tool's guidance section, which sits in the 100-199 + // band and names its tool without saying how it is reached. + ctx.systemPrompt.section({ name: 'tool:echo', order: 100, text: 'Use the echo tool.' }) + + const assembly = await systemPrompt.assemble() + const names = assembly.sections.map(section => section.name) + const rule = assembly.sections.find(section => section.name === 'tools:code-only') + expect(rule?.text).toContain(`\`${RUN_CODE_NAME}\` is the only tool you can call directly`) + // The rule is worthless after the guidance it qualifies. + expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tool:echo')) + expect(names.indexOf('tools:code-only')).toBeLessThan(names.indexOf('tools:sdk')) + }) + + it("mode 'both' omits the run_code-only rule, because native calls do execute there", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'both' }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + // Registered (the deployment is non-native) but empty, so the renderer + // drops it: `both` executes the native call the rule would forbid. + expect(assembly.sections.find(section => section.name === 'tools:code-only')?.text).toBe('') + expect(assembly.tools.map(tool => tool.name)).toContain('echo') + }) + it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => { const { ctx, systemPrompt } = await setup({ mode: 'code' }) let output: JsonSchemaNode = { type: 'string' } @@ -1574,6 +1600,11 @@ describe('the run_code dispatch bridge', () => { }) expect(result.isError).toBe(true) expect(result.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + // The name IS declared to this model, so a bare `unknown tool` reads as a + // broken deployment. The denial carries the route instead. + expect(result.error?.message).toBe( + `unknown tool "write": only \`${RUN_CODE_NAME}\` is callable directly — call \`write\` from inside a \`${RUN_CODE_NAME}\` program instead`, + ) }) it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => { From e258cf7a2ddee25aae08b968877690d39e1b9239 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 22:53:17 +0800 Subject: [PATCH 14/46] fix(tools): resolve the collapse through the scope, not the deployment default `collapses()` read `defaultMode`, so the collapse only applied when the DEPLOYMENT was `code`. An agent handed `code` by an agent preset under a native default announced `[run_code]` on the wire and still executed a model-direct native call -- the bypass this collapse exists to close, reopened for exactly the composition `dsh-agent-tool-mode` produces. `modeFor(scope)` is the same resolution `wireSchemas` and the SDK section already use, so presentation and execution cannot disagree, and a mode inherited from a standing preset scope collapses like a declared one. The per-agent and preset tests asserted only the wire, which is why the regression passed them. They now assert through the executor: the body never runs, the call resolves UNKNOWN_TOOL, and the native sibling beside it still executes. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/core/tools/src/index.ts | 15 +++++-- packages/core/tools/tests/code-mode.spec.ts | 45 ++++++++++++++++++++- 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ec81187785..db0acfe414 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: 6362022dd1b80e17997684574d9dc7f14372e23a -config-catalog.zh.md: 3fd335ee2b1ec6d1526f75ed9667f1d9286faafe +config-catalog.md: 39433b697588ede7b36f878490f4e0b095e8d67a +config-catalog.zh.md: d76b289f8a7ffbc434b2adeefd5f16142fdffc99 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6362022dd1..39433b6975 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2505,7 +2505,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:629`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3fd335ee2b..d76b289f8a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2506,7 +2506,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -来源:[`packages/core/tools/src/index.ts:617`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 492112405b..b6be912c17 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1221,7 +1221,7 @@ export class ToolRegistry extends Service { private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { const tool = this.get(name, scope) if (tool === undefined) return undefined - if (this.collapses(name, nested)) return undefined + if (this.collapses(name, scope, nested)) return undefined return tool } @@ -1311,11 +1311,18 @@ export class ToolRegistry extends Service { * `parent` token set) bypass the collapse. One home for the * security-relevant predicate, shared by {@link resolveExecution} and * {@link createExecution} so the two can never drift apart. + * + * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `code` + * by an agent preset under a native deployment is the composition + * `dsh-agent-tool-mode` exists for, and reading the deployment default would + * leave exactly that agent uncollapsed — announcing one surface while + * executing another, which is the bypass this collapse closes. * @param name - the tool name as registered. + * @param scope - the viewing scope whose effective presentation mode applies. * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. */ - private collapses(name: string, nested: boolean): boolean { - return !nested && this.defaultMode === 'code' && name !== RUN_CODE_NAME + private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean { + return !nested && this.modeFor(scope) === 'code' && name !== RUN_CODE_NAME } /** @@ -1371,7 +1378,7 @@ export class ToolRegistry extends Service { // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy // listeners still see every name that reaches the registry. const visible = this.get(name, agent) - const collapsed = visible !== undefined && this.collapses(name, parent !== undefined) + const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined) const concludingExecutions = this.concludingExecutions const base = { token, diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index eb6d1b22e0..4379f7e0b2 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1635,7 +1635,7 @@ describe('the run_code dispatch bridge', () => { describe('per-agent presentation', () => { it('gives one agent Code Mode while the deployment stays native', async () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) - registerEcho(ctx) + const calls = registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) scope.ctx.tools.presentAs('code') @@ -1644,6 +1644,17 @@ describe('per-agent presentation', () => { expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) expect(coded.sections.find(section => section.name === 'tools:sdk')?.text) .toContain('echo') + // Announced surface and callable surface must agree for THIS agent, whose + // mode is its own rather than the deployment's. + const denied = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('coded-direct'), + name: 'echo', + arguments: { value: 'coded' }, + agent, + }) + expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(calls).toEqual([]) // The deployment default is untouched: an agent that declared nothing — // and the global view behind it — still sees the native catalog. const native = await systemPrompt.assemble() @@ -1654,7 +1665,7 @@ describe('per-agent presentation', () => { it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') const { ctx, systemPrompt } = await setup({ mode: 'native' }) - registerEcho(ctx) + const calls = registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:code-like') @@ -1666,10 +1677,40 @@ describe('per-agent presentation', () => { expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() const coded = await systemPrompt.assemble({ scope: joined.agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + // Through the EXECUTOR, not just the wire: the deployment default is + // `native` here, so a collapse predicate reading it instead of this + // scope's effective mode would announce [run_code] and still execute the + // native call — the bypass, reopened for exactly the preset composition + // `dsh-agent-tool-mode` produces. + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('preset-coded-schedule'), + name: 'echo', + arguments: { value: 'joined' }, + agent: joined.agent, + })).toEqual({ kind: 'exclusive' }) + const denied = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('preset-coded-direct'), + name: 'echo', + arguments: { value: 'joined' }, + agent: joined.agent, + }) + expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(calls).toEqual([]) // A sibling that never parented stays native, as does the global view. expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() const native = await systemPrompt.assemble({ scope: loner.agent }) expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + const allowed = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('native-sibling-direct'), + name: 'echo', + arguments: { value: 'loner' }, + agent: loner.agent, + }) + expect(allowed).toMatchObject({ isError: false, value: 'echo:loner' }) + expect(calls).toEqual([{ value: 'loner' }]) }) it('keeps run_code out of a native agent\'s dispatch table', async () => { From 261349c95ebab042c61f5982210419536e950a9d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 22:55:53 +0800 Subject: [PATCH 15/46] test(acp): refresh both-mode expected prompt onto the merged base --- .../snapshots/both-mode-turn/system-prompt.expected.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 94e7ee4afb..3771a70950 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -21,6 +21,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +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. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -115,16 +117,16 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: this call returns only its subagent id, and the subagent works on its own from there. You are told when it finishes, so never poll or wait on it; `send_message` sends it more work. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call does not wait for it; you are told when it finishes. Send it more work with send_message. */ + /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; From db4e3e39a54223e9315d2e49275e01e09d745176 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 23:11:12 +0800 Subject: [PATCH 16/46] docs: refresh generated source references after the collapse-section constants Adding COLLAPSE_SECTION_ORDER and CODE_ONLY_INSTRUCTION shifted every later line in packages/core/tools/src/index.ts by 14, and three generated artifacts cite those lines: the subsystems cordis-surface region, the event producer/consumer matrix, and the Agent Note pair record left inconsistent by the cherry-pick resolution. --- ...026-08-07-code-mode-executor-collapse.i18n.yaml | 4 ++-- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 12 ++++++------ docs/event-producer-consumer.zh.md | 12 ++++++------ docs/subsystems/tools.i18n.yaml | 4 ++-- docs/subsystems/tools.md | 14 +++++++------- docs/subsystems/tools.zh.md | 14 +++++++------- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml index b81b0fe922..2b04b5b240 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md -2026-08-07-code-mode-executor-collapse.md: 817da95678edbceb807c45b3ef858b800eaaa6c2 -2026-08-07-code-mode-executor-collapse.zh.md: 7b4a1b8adc01f6611c50cc4325c02d4b60313898 +2026-08-07-code-mode-executor-collapse.md: 3b64675f90f830628dcde427d4388153ec7accf1 +2026-08-07-code-mode-executor-collapse.zh.md: 51b35ad44b9758805784834d1d036ba374140dd9 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 12daef3078..8494ab9aa3 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: c272087b03c6d27599b76ad6d6f197b023a36579 -event-producer-consumer.zh.md: b5634785e58134df453f0022a3b46ea926f3a526 +event-producer-consumer.md: 788d40e8dc110ffb5200889d9fdf17a5b17e8241 +event-producer-consumer.zh.md: 666f1f4108a3034af81d45b17c62537a4be30af6 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c272087b03..788d40e8dc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -45,12 +45,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b5634785e5..666f1f4108 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -47,12 +47,12 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 3974e0217f..a4cefc4ef5 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: fff43d587428ca4747cfd19ac5fccca08b4e048e -tools.zh.md: 4705edf81e56c81b237a69caa1c012ba12d67716 +tools.md: 60f982e1934a37f80236382141ebc54fdef5d7b4 +tools.zh.md: 07e5e5729bfb8329b2696c64c25b852cb8a91350 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index fff43d5874..60f982e193 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -571,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:762`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:787`](../../packages/core/tools/src/index.ts) @@ -596,7 +596,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:207`](../../packages/core/tools/src/index.ts) @@ -623,7 +623,7 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:189`](../../packages/core/tools/src/index.ts) @@ -647,7 +647,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) @@ -672,7 +672,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) @@ -695,7 +695,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts) @@ -716,5 +716,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:197`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 4705edf81e..07e5e5729b 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -571,7 +571,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:762`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:787`](../../packages/core/tools/src/index.ts) @@ -596,7 +596,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:207`](../../packages/core/tools/src/index.ts) @@ -623,7 +623,7 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:189`](../../packages/core/tools/src/index.ts) @@ -647,7 +647,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) @@ -672,7 +672,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) @@ -695,7 +695,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts) @@ -716,5 +716,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:197`](../../packages/core/tools/src/index.ts) From 8c31290abbf62a8461780ab3ae81f4445c620377 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:30:10 +0800 Subject: [PATCH 17/46] docs: propose unary API Remote migration --- ...-unary-apiproxy-remote-migration.i18n.yaml | 6 + ...6-08-10-unary-apiproxy-remote-migration.md | 124 ++++++++++++++++++ ...8-10-unary-apiproxy-remote-migration.zh.md | 124 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md create mode 100644 .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml new file mode 100644 index 0000000000..a3c2cffcb9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +2026-08-10-unary-apiproxy-remote-migration.md: aa3546ec2b79a0cd6d2866c194e9d6c1c22f9e33 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 653f560b38a1e6862ef6131f9e8189ed3cb3d6f8 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md new file mode 100644 index 0000000000..aa3546ec2b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -0,0 +1,124 @@ +# Agent Note: Migrate simple unary API Proxy calls to business Remote services + +Status: proposed + +English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md) + +## Problem + +The Host API Proxy still owns many unary methods whose implementation is only service lookup, argument projection, one business call, and response projection. That duplicates the contract across the business Service, API Proxy interface, Zod schemas, route table, client stub, and Client caller even though [TypeRT Remote calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) already let the business package own this class of call. + +Moving a method mechanically is not sufficient. Agent-bound API Proxy methods call `agentFor()`, which reuses a live Agent, resumes an ordinary cold Session with its recorded preset, deduplicates concurrent resumes, and rejects subagent-owned identities. A Remote method that resolved an `Agent` or `Session` differently would change lifecycle behavior even when the final business call looked identical. + +The API Proxy also contains BFF operations whose contract is not a business method: Session lifecycle and transcript assembly, model-selection state, live-only input control, configuration filtering, skill presentation, Host composition facts, and native desktop operations. Stateful interactions and streams have different lifecycles again. Treating all unary syntax as evidence that a method is simple would move product policy into arbitrary Service packages or force new packages that have no independent business owner. + +Finally, Connection currently applies its loopback-only privileged-method list inside the API Proxy fallback. A TypeRT interceptor claims its endpoint before that fallback, so migrating credential or preset authoring calls without moving the privilege check would grant trusted-LAN callers operations that are currently loopback-only. + +## Proposal + +Migrate only unary calls whose business operation already has a natural Service owner and whose remaining adaptation is a small parameter or result projection. The Service binds a TypeRT namespace and decorates an existing method directly with `@Remote` when its signature is the intended consumer contract. A new method is justified only when it performs real adaptation; an identity `remote*` forwarding wrapper is not. + +`@deepseek-ai/dsh-api-remotes/client` will mount each selected business package's generated `/remote` contribution. Client business packages will call `ctx.remote.` and perform Client-owned joins or presentation projection there. The corresponding API Proxy interface member, schema, route, handler, generated client method, fixture implementation, and production invocation will be removed together in that Service's vertical commit. + +Large BFF methods remain in `dsh-host-apiproxy`. A method leaves this migration if implementation discovers endpoint-specific lifecycle policy, substantial orchestration, a Client dependency on a protocol-only error distinction, or a transport shape that cannot be expressed as a small owner-side adapter. + +## Migration set + +| Legacy RPC | Remote destination | Host method | Adaptation | +|---|---|---|---| +| `session.rename` | `ctx.remote.sessionTitle` in `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | Direct `@Remote`; Client maps `eventSeq` to its title projection sequence. | +| `command.list`, `command.execute` | `ctx.remote.commands` in `@deepseek-ai/dsh-commands` | `CommandService.list(Agent)`, `execute(Agent, line, signal)` | Direct `@Remote`; Client maps `undefined` to unmatched and preserves caller cancellation. | +| `llm.providers` | `ctx.remote.llm` in `@deepseek-ai/dsh-llm` | `LlmService.listProviders()`, `listConfigurableProviders()` | Direct `@Remote` on both reads; the Client joins registration and configuration-directory rows. | +| `credentials.describe`, `credentials.set`, `credentials.unset` | `ctx.remote.credentials` in `@deepseek-ai/dsh-credentials-local` | `CredentialsLocal.describe(ref)`, `set(ref, value)`, `unset(ref)` | Direct `@Remote`; Client batches `describe` calls when its UI requests several refs. | +| `agentPreset.read`, `agentPreset.copy`, `agentPreset.remove` | `ctx.remote.agentPresets` in `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`, `copy(from, id, name?)`, `remove(id)` | `copy` and `remove` are direct; `readDocument` combines stored content with metadata from one live discovery. | +| `subagent.interrupt` | `ctx.remote.subagents` in `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | Adapter constructs the internal user-authority variant without resolving or resuming either Agent. | +| `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | `ctx.remote.workspace` in `@deepseek-ai/dsh-workspace` | `snapshot()`, `insertSessionBefore(workspaceId, sessionId, before?)`, `archiveSession(sessionId)` | Registry adapters detach mutable entities and return the settled workspace or archive snapshot. | + +The Remote API deliberately follows Service names rather than preserving dotted legacy names. For example, Session rename becomes `ctx.remote.sessionTitle.rename(...)`. + +## Deferred API Proxy domains + +| Domain | Methods | Reason retained in the API Proxy | +|---|---|---| +| Session Host lifecycle | `session.list`, `search`, `create`, `fork` | Cross-Agent persistence, Workspace assignment, preset composition, and creation policy. | +| Session transcript | `session.history`, `attachment`, `subagent.history` | Cold/live logs, pagination, projections, presenters, and attachment authorization. | +| Agent model selection | `session.models`, `selectModel` | Per-Agent state, model validation, and default persistence are BFF policy. | +| Agent input and control | `session.prompt`, `updateQueue`, `cancel` | Image admission, Inbox mutation, and endpoint-specific live-only semantics. | +| Configuration Remote | `settings.describe`, `openDocument`, `update`, `replace`, `mutate` | Namespace exposure, redaction, revision checks, and native opening are product policy. | +| Session skill catalog | `skill.list` | Cold Sessions must not resume; preset standing scope and presenter filtering are BFF joins. | +| Host runtime information | `host.describe` | Version, cwd, default model, and attached count combine several Host owners. | +| Host path opening | `host.openPath`, `agentPreset.openDocument` | Native desktop authority and cancellation belong to the Host composition. | +| Remaining preset, subagent, and workspace calls | `agentPreset.list`, `select`; `subagent.list`, `history`, `prompt`; `workspace.create`, `rename`, `delete` | These calls contain roster policy, live/cold joins, authorization, or serialized multi-operation ordering. | +| Stateful and streaming protocol | approvals, questions, responses, mux and Host streams | They are not one-request/one-result business calls. | + +`workspace.delete` stays with `create` and `rename` because all three participate in the same serialized creation/name/delete chain. Splitting one method out would make the Service and API Proxy observe different operation orders. + +## Agent and Session lookup equivalence + +`createApiRemoteAgentResolver()` constructs one resolver and returns it as the API Proxy's `agentFor`. The same closure is installed through `ctx.typert.lookups.configure('agent', ...)`, `ctx.typert.lookups.configure('session', ...)`, and `ctx.typert.contexts.configureHost('agent', ...)`. Therefore a Remote `Agent` or `Session` parameter and a legacy `agentFor()` call share the same live lookup, in-flight resume table, persistence inspection, preset-aware setup, and ownership fence. + +The migration must pin these outcomes with integration tests: + +- a live ordinary Agent is reused without a resume; +- an ordinary cold Session resumes with its persisted header, events, and recorded preset setup; +- concurrent Agent and Session lookups for one id share one resume; +- a live or cold subagent-owned identity fails with `agent-busy` before business invocation; +- an id missing from durable persistence fails with `session-not-found`; +- resolver failures keep their existing `RpcError` through `TypeRTLookupFailure`. + +Lookup policy is key-wide, not endpoint-specific. Methods such as prompt, queue editing, cancellation, model selection, and skill listing cannot use the shared `agent` or `session` lookup while retaining live-only or no-resume behavior, so they remain in the API Proxy until TypeRT supports an explicit per-endpoint policy. + +Methods whose signatures contain only branded ids do not invoke TypeRT object lookup. `subagents.interruptByParent()` must retain the existing process-local Activation lookup and parent-offline behavior: it does not call `agentFor`, read the catalog, inspect persistence, or cold-resume a parent or child. + +## Client and error behavior + +Generated Remote methods return business values and throw an Error whose `cause` contains the existing RPC failure. Client business services own adaptation to their current result/store interfaces. They must settle successful results immediately exactly as they do today so event frames remain idempotent replays rather than the only update path. + +Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypeRTLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure. + +## Privileged authority + +Connection must enforce privileged endpoint authority before choosing the TypeRT interceptor or API Proxy fallback. The check must recognize both legacy dotted names and Remote slash endpoints and keep these migrated operations loopback-only: + +- `agentPresets/readDocument`, `agentPresets/copy`, and `agentPresets/remove`; +- `credentials/describe`, `credentials/set`, and `credentials/unset`. + +The carrier-wide trusted-host and origin checks remain unchanged. This is a non-escalation requirement: endpoint ownership may change, but the set of callers authorized to invoke the operation may not widen. + +## Commit boundaries + +The migration lands as an RFC commit, one vertical commit for each Service, and one final integration commit. A Service commit includes its Host binding and decorators, generated-contract package declarations, API Remotes mount, Client business adoption, and removal of that Service's legacy API Proxy route and production client call. Service commits may be temporarily red because generated artifacts and shared fixtures are reconciled once in the final integration commit. + +The final commit generates every `/remote` artifact from a clean state, updates shared fixtures and tests, moves this note to `implemented`, updates the still-authoritative protocol documentation where central unary ownership changed, and runs the selected repository gates. + +## Alternatives considered + +**Keep simple methods in the central API Proxy.** This preserves one transport facade but continues the duplicated interfaces, schemas, route rows, stubs, and business projections that TypeRT was introduced to remove. + +**Move every unary API Proxy method.** Unary syntax does not imply single-owner behavior. Session orchestration, live-only control, configuration exposure, and native Host operations would either leak BFF policy into generic Services or create ownerless packages. + +**Give Remote methods a separate resume implementation.** A second resolver could drift on preset restoration, concurrent deduplication, or subagent ownership. Sharing the exact closure with legacy `agentFor()` makes equivalence an implementation fact rather than a promise. + +**Preserve every legacy RPC name and response envelope.** That would turn business packages into copies of the old protocol. Service-oriented names and business values let the Client own joins while Connection continues to own the one RPC envelope. + +**Trust the API Proxy fallback to enforce privileged methods.** Interceptor selection bypasses that fallback, so this would silently widen authority for migrated methods. + +## Acceptance criteria + +- Every migration-table method is callable through its listed `ctx.remote` Service and has no production legacy API Proxy route, schema, map row, client stub, or invocation. +- Existing methods with matching signatures carry `@Remote` directly; every added method performs the adaptation stated in the table and no identity `remote*` wrapper remains. +- Agent/Session integration tests prove the shared lookup outcomes, and subagent interrupt tests prove no cold resume occurs. +- Privileged migrated endpoints reject trusted non-loopback callers and accept loopback callers before either dispatch path runs. +- Client behavior and immediate state settlement remain equivalent for every migrated call, including cancellation where supported. +- Deferred methods remain on the API Proxy with their existing behavior. +- A clean generation/build produces and consumes every selected Remote contribution, and focused tests plus final repository gates pass. + +## Risks + +Removing legacy schemas also removes their protocol-specific error taxonomy. A hidden Client branch on one of those codes would make the call non-simple and must be discovered before its Service commit is accepted. + +Generated Remote contracts add build ordering and publication entries to each business package. Missing one runtime mount, declaration export, source-map source, package dependency, or Project Reference can pass a narrow source test while failing a clean Client build. + +Moving privilege enforcement to composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass the loopback decision. + +This note applies the existing TypeRT Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md new file mode 100644 index 0000000000..653f560b38 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -0,0 +1,124 @@ +# Agent Note: 将简单的一元 API Proxy 调用迁移到业务 Remote 服务 + +Status: proposed + +[English](2026-08-10-unary-apiproxy-remote-migration.md) | 中文 + +## 问题 + +Host API Proxy 仍承载许多一元方法。这些方法的实现仅执行服务查找、参数投影、一次业务调用和响应投影。尽管 [TypeRT Remote 调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)已经允许业务包承载此类调用,这种做法仍会在业务服务、API Proxy 接口、Zod schema、路由表、客户端 stub 和 Client 调用方之间重复定义同一约定。 + +仅机械迁移方法并不足够。与 Agent 绑定的 API Proxy 方法会调用 `agentFor()`:它复用 live Agent,使用普通冷 Session 中记录的 preset 恢复该 Session,对并发恢复去重,并拒绝由 subagent 拥有的 identity。如果 Remote 方法以不同方式解析 `Agent` 或 `Session`,即使最终业务调用看起来相同,也会改变生命周期行为。 + +API Proxy 还包含一些不以业务方法为约定的 BFF 操作:Session 生命周期与 transcript(文本记录)组装、模型选择状态、仅限 live 的输入控制、配置过滤、skill(技能)呈现、Host 组合信息和原生桌面操作。有状态交互与流又具有不同的生命周期。若把一元调用的语法一概视为方法简单的依据,就会把产品策略移入任意服务包,或者迫使系统新增没有独立业务所有者的包。 + +最后,Connection 目前在 API Proxy 回退路径内执行仅限环回地址的特权方法清单。TypeRT interceptor 会先于该回退路径认领自己的端点,因此,如果迁移凭据或 preset 创作调用时不一并迁移权限检查,受信任的局域网调用方就会获得目前仅向环回调用方开放的操作权限。 + +## 提案 + +只迁移符合以下条件的一元调用:其业务操作已经有自然归属的服务,且其余适配只是少量参数或结果投影。当现有方法的签名就是预期的消费方约定时,服务应绑定 TypeRT namespace,并直接使用 `@Remote` 装饰现有方法。只有执行实质性适配时才有理由新增方法;不得添加只做恒等转发的 `remote*` 包装层。 + +`@deepseek-ai/dsh-api-remotes/client` 将挂载所选各业务包生成的 `/remote` 贡献。Client 业务包将调用 `ctx.remote.`,并在包内执行归 Client 所有的关联或呈现投影。对应的 API Proxy 接口成员、schema、路由、处理程序、生成的客户端方法、fixture(测试前置数据)实现和生产调用点,将在该服务的纵向提交中一并移除。 + +大型 BFF 方法仍留在 `dsh-host-apiproxy` 中。如果实现过程中发现某个方法包含端点特有的生命周期策略、大量编排、Client 依赖仅存在于协议层的错误区分,或者其传输数据结构无法用归属方的小型适配器表达,则该方法不在此次迁移范围内。 + +## 迁移集合 + +| 旧 RPC | Remote 目标 | Host 方法 | 适配 | +|---|---|---|---| +| `session.rename` | `ctx.remote.sessionTitle`,位于 `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | 直接使用 `@Remote`;Client 将 `eventSeq` 映射到自身的标题投影序列。 | +| `command.list`、`command.execute` | `ctx.remote.commands`,位于 `@deepseek-ai/dsh-commands` | `CommandService.list(Agent)`、`execute(Agent, line, signal)` | 直接使用 `@Remote`;Client 将 `undefined` 映射为未匹配结果,并保留调用方的取消行为。 | +| `llm.providers` | `ctx.remote.llm`,位于 `@deepseek-ai/dsh-llm` | `LlmService.listProviders()`、`listConfigurableProviders()` | 两项读取都直接使用 `@Remote`;Client 关联注册行与配置目录行。 | +| `credentials.describe`、`credentials.set`、`credentials.unset` | `ctx.remote.credentials`,位于 `@deepseek-ai/dsh-credentials-local` | `CredentialsLocal.describe(ref)`、`set(ref, value)`、`unset(ref)` | 直接使用 `@Remote`;当 UI 请求多个 ref 时,Client 批量发起 `describe` 调用。 | +| `agentPreset.read`、`agentPreset.copy`、`agentPreset.remove` | `ctx.remote.agentPresets`,位于 `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`、`copy(from, id, name?)`、`remove(id)` | `copy` 和 `remove` 直接暴露现有方法;`readDocument` 将存储的内容与一次实时发现取得的元数据组合。 | +| `subagent.interrupt` | `ctx.remote.subagents`,位于 `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | 适配器构造内部的用户权限变体,不解析也不恢复任一 Agent。 | +| `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | `ctx.remote.workspace`,位于 `@deepseek-ai/dsh-workspace` | `snapshot()`、`insertSessionBefore(workspaceId, sessionId, before?)`、`archiveSession(sessionId)` | 注册表适配器分离可变实体,并返回已完成更新的 workspace 或归档快照。 | + +Remote API 有意采用服务名称,而不保留旧 RPC 的点分名称。例如,Session 重命名将变为 `ctx.remote.sessionTitle.rename(...)`。 + +## 暂缓迁移的 API Proxy 领域 + +| 领域 | 方法 | 保留在 API Proxy 中的原因 | +|---|---|---| +| Session Host 生命周期 | `session.list`、`search`、`create`、`fork` | 跨 Agent 持久化、Workspace 分配、preset 组合和创建策略。 | +| Session transcript | `session.history`、`attachment`、`subagent.history` | cold/live 日志、分页、投影、呈现器和附件授权。 | +| Agent 模型选择 | `session.models`、`selectModel` | 各 Agent 的状态、模型校验和默认值持久化属于 BFF 策略。 | +| Agent 输入与控制 | `session.prompt`、`updateQueue`、`cancel` | 图片准入、Inbox 变更和端点特有的仅限 live 语义。 | +| 配置 Remote | `settings.describe`、`openDocument`、`update`、`replace`、`mutate` | namespace 暴露、脱敏、修订检查和原生打开操作属于产品策略。 | +| Session skill 目录 | `skill.list` | 不得恢复冷 Session;preset 的常驻 scope 和呈现器过滤属于 BFF 关联操作。 | +| Host 运行时信息 | `host.describe` | 版本、cwd、默认模型和当前已附加的 Session 数量来自多个 Host 所有者。 | +| Host 路径打开 | `host.openPath`、`agentPreset.openDocument` | 原生桌面权限和取消属于 Host 组合。 | +| 其余 preset、subagent 和 workspace 调用 | `agentPreset.list`、`select`;`subagent.list`、`history`、`prompt`;`workspace.create`、`rename`、`delete` | 这些调用包含名单策略、live/cold 关联、授权或多项操作的串行执行顺序。 | +| 有状态协议和流式协议 | 审批、问题、响应、mux 和 Host 流 | 它们不是一次请求/一次结果的业务调用。 | + +`workspace.delete` 与 `create` 和 `rename` 保持在一起,因为三者都参与同一条串行的创建/命名/删除操作链。单独迁出一个方法会使服务与 API Proxy 观察到不同的操作顺序。 + +## Agent 与 Session lookup 等价性 + +`createApiRemoteAgentResolver()` 构造一个 resolver,并将其作为 API Proxy 的 `agentFor` 返回。同一个 closure 通过 `ctx.typert.lookups.configure('agent', ...)`、`ctx.typert.lookups.configure('session', ...)` 和 `ctx.typert.contexts.configureHost('agent', ...)` 安装。因此,Remote `Agent` 或 `Session` 参数与旧版 `agentFor()` 调用共享同一套 live lookup、进行中的恢复表、持久化检查、感知 preset 的 setup 和 ownership fence。 + +迁移必须用集成测试固定以下结果: + +- 直接复用普通的 live Agent,不执行恢复; +- 根据持久化的 header、事件和已记录的 preset setup 恢复普通冷 Session; +- 对同一个 id 并发执行 Agent 与 Session lookup 时,共享同一次恢复; +- 无论 live 还是 cold,由 subagent 拥有的 identity 都会在业务调用前以 `agent-busy` 失败; +- 持久化存储中不存在的 id 以 `session-not-found` 失败; +- resolver 失败会保留现有的 `RpcError`,并通过 `TypeRTLookupFailure` 传递。 + +Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列编辑、取消、模型选择和 skill 列表等方法如果使用共享 `agent` 或 `session` lookup,就无法保留仅限 live 或禁止恢复的行为,因此在 TypeRT 支持显式的逐端点策略之前,这些方法仍留在 API Proxy 中。 + +签名只包含 branded id 的方法不会调用 TypeRT 对象 lookup。`subagents.interruptByParent()` 必须保留现有的进程内 Activation lookup 和父级离线行为:它不会调用 `agentFor`、读取目录、检查持久化,也不会冷恢复父 Agent 或子 Agent。 + +## Client 与错误行为 + +生成的 Remote 方法返回业务值,并抛出一个 Error,其 `cause` 包含现有的 RPC 失败。Client 业务服务负责适配到当前的结果/store 接口。它们必须像当前一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。 + +Resolver 拥有的 `session-not-found` 和 `agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypeRTLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。 + +## 特权调用权限 + +Connection 必须在选择 TypeRT interceptor 或 API Proxy 回退路径之前检查调用方是否有权访问特权端点。该检查必须同时识别旧式点分名称和 Remote 斜杠端点,并保持以下已迁移操作仅限环回地址: + +- `agentPresets/readDocument`、`agentPresets/copy` 和 `agentPresets/remove`; +- `credentials/describe`、`credentials/set` 和 `credentials/unset`。 + +贯穿整个载体的 trusted-host 和 origin 检查保持不变。这是一项非升权要求:端点所有权可以变化,但获准调用该操作的调用方集合不得扩大。 + +## 提交边界 + +此次迁移将以一个 RFC 提交、每项服务各一个纵向提交,以及一个最终集成提交落地。服务提交包含其 Host 绑定与装饰器、生成约定所需的包声明、API Remotes 挂载、Client 业务接入,以及移除该服务的旧版 API Proxy 路由和生产客户端调用。服务提交可能暂时无法通过门禁,因为生成产物和共享 fixture 将在最终集成提交中统一调整。 + +最终提交从干净状态生成所有 `/remote` 产物,更新共享 fixture 和测试,将本文移至 `implemented`,更新中央一元调用所有权发生变化之处仍具权威性的协议文档,并运行选定的仓库门禁。 + +## 考虑过的替代方案 + +**将简单方法保留在中央 API Proxy 中。** 这会保留统一的传输外观,但仍会延续 TypeRT 原本要消除的重复接口、schema、路由行、stub 和业务投影。 + +**迁移每一个一元 API Proxy 方法。** 一元调用形式并不表示行为只有一个所有者。Session 编排、仅限 live 的控制、配置暴露和原生 Host 操作要么会把 BFF 策略泄漏到通用服务中,要么会产生没有所有者的包。 + +**为 Remote 方法提供单独的恢复实现。** 第二个 resolver 可能在 preset 恢复、并发去重或 subagent 所有权方面出现偏差。与旧版 `agentFor()` 共享完全相同的 closure,使等价性成为实现事实,而不只是一项承诺。 + +**保留每一个旧版 RPC 名称和响应 envelope。** 这会使业务包变成旧协议的副本。面向服务的名称和业务值让 Client 负责关联操作,而 Connection 继续负责统一的 RPC envelope。 + +**依赖 API Proxy 回退路径强制执行特权方法权限。** interceptor 选择会绕过该回退路径,因此这会悄然扩大已迁移方法的权限范围。 + +## 验收标准 + +- 迁移表中的每个方法都可通过表中列出的 `ctx.remote` 服务调用,并且不存在生产环境中的旧版 API Proxy 路由、schema、映射表行、客户端 stub 或调用。 +- 签名匹配的现有方法直接带有 `@Remote`;每个新增方法都执行表中所述的适配,且不保留只做恒等转发的 `remote*` 包装层。 +- Agent/Session 集成测试证明共享 lookup 的各项结果,subagent 中断测试证明不会发生冷恢复。 +- 已迁移的特权端点拒绝受信任的非环回调用方,并接受环回调用方,且该判定在任一分发路径运行前完成。 +- 每项已迁移调用的 Client 行为和立即提交状态的行为保持等价,包括支持取消之处的取消行为。 +- 暂缓迁移的方法及其现有行为仍保留在 API Proxy 上。 +- 一次从干净状态开始的生成与构建会生成并消费所选的每项 Remote 贡献,且聚焦测试和最终仓库门禁均通过。 + +## 风险 + +移除旧版 schema 也会移除其协议特有的错误分类。如果 Client 中存在依赖其中某个错误码的隐蔽分支,该调用就不是简单调用,必须在接受相应服务提交前发现它。 + +生成的 Remote 约定会为每个业务包引入构建顺序要求和发布条目。如果遗漏运行时挂载、声明导出、source map 来源、包依赖或 Project Reference 中的任何一项,局部源码测试可能仍会通过,但从干净状态开始的 Client 构建会失败。 + +将权限强制执行移至复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的端点和一个旧版回退端点,确保两条路径都无法绕过环回判定。 + +本文应用现有 TypeRT Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md)仍具权威性,无需执行归档操作。 From 027e5fe9a4b4667ad364243212b262a66de125e2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:09:13 +0800 Subject: [PATCH 18/46] feat(typert): carry Remote absence without a second result envelope Absence crosses the wire as a missing field: an omitted argument and a void or undefined result both arrive as an absent JSON member, and the wide RPC result slot accepts a success response without a value. Parameters declared optional stay optional in the generated consumer declaration, so a business signature is never widened to `T | undefined` to suit the wire. The weak SRC descriptor reads parameter names from a JavaScript signature and cannot see optionality, so a source-launched Host accepts an absent field and the strict LIB pass owns rejecting a genuinely missing required parameter. --- packages/api/gateway/src/index.ts | 44 +++++++++++- packages/api/gateway/tests/gateway.spec.ts | 45 +++++++++++- packages/host/apiproxy/src/api/rpc.schema.ts | 8 ++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 68 +++---------------- packages/typert/generator/src/analyzer.ts | 54 ++++++++++++--- packages/typert/generator/src/model.ts | 4 ++ packages/typert/registry/src/service.ts | 3 + 7 files changed, 149 insertions(+), 77 deletions(-) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index ee4b063622..9b7ba6f2b1 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -70,6 +70,18 @@ export class TypertGatewayError extends Error { } } +/** Business invocation lost its carrier cancellation race. */ +class RemoteInvocationCancelled extends Error { + /** + * @param endpoint - canonical Remote endpoint. + * @param cause - business rejection observed after carrier cancellation. + */ + constructor(endpoint: string, cause: unknown) { + super(`Remote invocation "${endpoint}" was aborted`, { cause }) + this.name = 'RemoteInvocationCancelled' + } +} + /** * Resolve strict generated definitions or conservative SRC markers against * current Cordis Services and TypeRT providers. @@ -157,7 +169,13 @@ export class TypertGatewayService extends Service implements TypertGateway { ) } - const result = await Reflect.apply(method, receiver, args) as unknown + let result: unknown + try { + result = await Reflect.apply(method, receiver, args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error) + throw error + } return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') } @@ -190,6 +208,9 @@ export class TypertGatewayService extends Service implements TypertGateway { args: payload.args, signal, }) + // 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 } } catch (error) { return rpcFailure(error) @@ -439,6 +460,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } function rpcFailure(error: unknown): ConnectionRpcResult { + if (error instanceof RemoteInvocationCancelled) { + return { + ok: false, + error: { code: 'cancelled', message: error.message, details: {} }, + } + } if (error instanceof TypeRTLookupFailure) { return { ok: false, error: error.failure as ConnectionRpcError } } @@ -559,7 +586,15 @@ function assertExactArguments( if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) const actual = Reflect.ownKeys(args) const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key)) - const missing = [...expected].filter(key => !Object.hasOwn(args, key)) + // A JSON field may be omitted when the strict descriptor declares absence, + // and always under SRC: a weak descriptor reads parameter names from the + // JavaScript signature and cannot see which are optional, so LIB is where an + // omitted required argument is caught. Lookup ids are never omissible. + const acceptsMissing = new Set(descriptor.parameters + .filter(parameter => parameter.source === 'json' + && (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json')) + .map(parameter => parameter.wire)) + const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key)) if (extra.length === 0 && missing.length === 0) return const clauses: string[] = [] if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) @@ -575,7 +610,10 @@ function decode( field: string, ): unknown { try { - if (codec.mode === 'strict') value = codec.schema.parse(value) + if (codec.mode === 'strict') { + value = codec.schema.parse(value) + if (value === undefined) return value + } assertJsonValue(value, new Set()) return value } catch (cause) { diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index 38be5c822d..c6f3a59768 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -77,6 +77,12 @@ class GoalService extends Service { return this.nextResult === undefined ? value : this.nextResult } + @Remote + maybe(value: string | null | undefined): string | null | undefined { + this.calls.push('maybe') + return value + } + @Remote fail(request: unknown): never { void request @@ -945,7 +951,7 @@ describe('TypertGatewayService', () => { expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) registerAgentLookup(ctx, { id: 'agent-1' }) - registerStrict(ctx, [createDescriptor()]) + registerStrict(ctx, [createDescriptor(), maybeDescriptor()]) expect(connection.matches?.('goals/create')).toBe(true) expect(connection.matches?.('goals/passthrough')).toBe(true) expect(connection.matches?.('goals')).toBe(false) @@ -973,6 +979,15 @@ 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({ + ok: true, + value: undefined, + }) + await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({ + ok: true, + value: null, + }) + for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { const result = await handler(endpoint, { args: {} }, signal) expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) @@ -987,7 +1002,11 @@ describe('TypertGatewayService', () => { } service.businessError = 'non-error failure' as unknown as Error - await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ + await expect(handler( + 'goals/fail', + { args: { request: null } }, + new AbortController().signal, + )).resolves.toEqual({ ok: false, error: { code: 'internal', message: 'non-error failure', details: {} }, }) @@ -1302,6 +1321,28 @@ function strictOnlyDescriptor(): InvocationDescriptor { } } +function maybeDescriptor(): InvocationDescriptor { + const value = strictCodec( + '@fixture/gateway#MaybeValue', + z.union([z.string(), z.null(), z.undefined()]), + ) + return { + id: '@fixture/gateway#goals/maybe', + service: 'goals', + namespace: 'goals', + method: 'maybe', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + acceptsUndefined: true, + codec: value, + }], + result: value, + } +} + async function expectCode( promise: Promise, code: TypertGatewayError['code'], diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index b508e25f93..53f3c34ec2 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -61,7 +61,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), - z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), @@ -91,6 +90,9 @@ export function rpcResultSchema(value: z.ZodType): z.ZodUnion /** ServerRequest full form (payload stays wide). */ @@ -119,7 +121,7 @@ export const serverRequestSchema = z.object({ export const clientResponseSchema = z.object({ type: z.literal('client-response'), rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown()), + result: rpcResultSchema(z.unknown().optional()), }) as unknown as z.ZodType /** Wire full-form union (discriminated by type). */ diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9a8f9ecc55..0e8ab81ec2 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,17 +20,10 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { - workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, - workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, - workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' -import { - commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, - commandListRequestSchema, commandListValueSchema, -} from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -115,9 +108,14 @@ describe('wire full-form schemas', () => { expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() }) - it('rejects a quadrant missing its members', () => { + it('rejects a quadrant missing its members but accepts a valueless success result', () => { expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() - expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + // A void business result carries no value field; the endpoint's own second + // parse is what requires a value for methods that return data. + expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId) + .toBe('r1') }) }) @@ -346,22 +344,11 @@ describe('workspace domain schemas', () => { createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', } - it('validates ids, the view row, and list request/value', () => { + it('validates ids and the view row', () => { expect(workspaceIdSchema.parse('w1')).toBe('w1') expect(() => workspaceIdSchema.parse('')).toThrow() expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() - expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) - expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() - }) - - it('archiveSession request/value carry the id and the full updated set', () => { - expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() - expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) - .toEqual(['s1', 's2']) - expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) it('create requires a path', () => { @@ -384,45 +371,6 @@ describe('workspace domain schemas', () => { expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() }) - - it('insertSessionBefore accepts an anchored and an anchorless move', () => { - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() - expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() - expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') - }) -}) - -describe('commands domain schemas', () => { - it('validates the catalog request/value pair', () => { - expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - // The wire is session-addressed only: a sessionId-less payload fails. - expect(() => commandListRequestSchema.parse({})).toThrow() - expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([]) - const value = commandListValueSchema.parse({ commands: [ - { name: 'plan', description: 'Toggle plan mode' }, - { name: 'goal', description: 'Set the goal', input: { hint: '' } }, - ] }) - expect(value.commands[1]?.input?.hint).toBe('') - expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined() - expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow() - expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow() - }) - - it('validates the execute request/value pair with both matched branches', () => { - expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off') - // Both members are mandatory: dropping either fails the parse. - expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow() - expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow() - expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false }) - // Pure admission: matched plus the optional lifecycle pairing id - // (outcomes ride the logged lifecycle events, never this response). - expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' })) - .toEqual({ matched: true, commandId: 'cmd-1' }) - expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true }) - expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow() - expect(() => commandExecuteValueSchema.parse({})).toThrow() - }) }) describe('skills domain schemas', () => { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 358edceb66..c922232fe7 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -983,8 +983,8 @@ class FaceAnalyzer { } if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') - if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const optional = parameter.questionToken !== undefined const authoredType = this.requiredType(parameter, parameter.type, 'parameter') const cancellationName = parameter.name.text === 'signal' const cancellationType = this.isGlobalAbortSignal(authoredType) @@ -1002,6 +1002,7 @@ class FaceAnalyzer { const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel if (lookup !== undefined) { + if (optional) this.fail(parameter, `lookup parameter for ${lookup.key} cannot be optional`) if (parameter.name.text !== lookup.key) { this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) } @@ -1025,10 +1026,13 @@ class FaceAnalyzer { name: parameter.name.text, wire: parameter.name.text, source: 'json', + ...optional ? { optional: true as const } : {}, boundary: this.remoteBoundary( authoredType, `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, false, + 'undefined', + optional, ), } } @@ -1095,6 +1099,7 @@ class FaceAnalyzer { resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, false, + 'undefined-or-void', ), location: this.location(method.name), } @@ -1336,9 +1341,18 @@ class FaceAnalyzer { authoredType: ts.TypeNode, fallbackTypeSymbol: string, requireNamed: boolean, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject', + optional = false, ): RemoteBoundaryModel { const type = this.convertType(authoredType) - const codecType = this.resolvedRemoteCodecType(authoredType) + const declaredType = this.checker.getTypeFromTypeNode(authoredType) + // An optional parameter's authored node carries no `undefined`; the codec + // still has to accept the omitted wire field the consumer sends. + const resolvedType = optional + ? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined) + : declaredType + const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence) + const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType) const rootSymbol = this.namedWorkspaceType(authoredType) const imports = new Map() const visit = (node: ts.Node): void => { @@ -1365,6 +1379,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: `${imported.specifier}#${imported.name}`, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1374,6 +1389,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: fallbackTypeSymbol, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1387,9 +1403,18 @@ class FaceAnalyzer { * validated without teaching the compiler-independent emitter TypeScript's * type evaluator. */ - private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { - const resolvedType = this.checker.getTypeFromTypeNode(authoredType) - this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) + private resolvedRemoteCodecType( + authoredType: ts.TypeNode, + resolvedType: ts.Type, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void', + ): TypeNodeId { + this.assertRemoteJsonType( + resolvedType, + authoredType, + new Set(), + topLevelAbsence !== 'reject', + topLevelAbsence === 'undefined-or-void', + ) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1554,9 +1579,11 @@ class FaceAnalyzer { site: ts.TypeNode, active: Set, allowUndefined: boolean, + allowVoid: boolean, ): void { const flags = type.flags if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & ts.TypeFlags.Void) !== 0 && allowVoid) return if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) } @@ -1569,13 +1596,15 @@ class FaceAnalyzer { | ts.TypeFlags.Null | ts.TypeFlags.Never)) !== 0) return if (type.isUnion()) { - for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + for (const member of type.types) { + this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid) + } 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) + for (const member of material) this.assertRemoteJsonType(member, site, active, false, false) return } if ((flags & ts.TypeFlags.TypeParameter) !== 0) { @@ -1606,6 +1635,7 @@ class FaceAnalyzer { site, active, (elementFlags & ts.ElementFlags.Optional) !== 0, + false, ) }) return @@ -1613,7 +1643,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) + this.assertRemoteJsonType(element, site, active, false, false) return } const properties = this.checker.getPropertiesOfType(type) @@ -1628,19 +1658,25 @@ class FaceAnalyzer { site, active, (property.flags & ts.SymbolFlags.Optional) !== 0, + false, ) } 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) + this.assertRemoteJsonType(info.type, site, active, false, false) } } finally { active.delete(type) } } + 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)) + } + private isRemotePhantomConstraint(type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 81bc6a91a1..40eb31dde8 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -107,6 +107,8 @@ export interface RemoteBoundaryModel { readonly type: TypeNodeId /** Checker-resolved projection used only to emit the runtime codec. */ readonly codecType: TypeNodeId + /** Whether the authored top-level boundary explicitly accepts `undefined`. */ + readonly acceptsUndefined: boolean readonly typeSymbol: string readonly imports: readonly RemoteTypeImportModel[] } @@ -117,6 +119,8 @@ export interface InvocationParameterModel { readonly wire: string readonly source: 'json' | 'lookup' readonly lookup?: string + /** Authored as an optional parameter, so consumers may omit the wire field. */ + readonly optional?: true readonly boundary: RemoteBoundaryModel } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index b8bab3a121..06e2161a1e 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -653,6 +653,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } wires.add(parameter.wire) if (parameter.source === 'lookup') { + if (parameter.acceptsUndefined !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" cannot accept undefined`) + } if (parameter.lookup === undefined) { throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) } From a2981207b059fcc701806b31c0c141aa3e241374 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:09:31 +0800 Subject: [PATCH 19/46] feat(typert): deliver the carrier outcome from ctx.remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated Remote method now resolves to `RemoteResult`: the Client face folds a carrier failure, a transport throw and a rejected result payload into one error branch, so no consumer wraps a call to recover them. Only assembly faults still reject — a wrong argument count, an unmounted method, a missing Context binder, an absent Connection. `RemoteFailure.code` stays an open string because the closed RPC code union lives in the carrier package, which already depends on type-meta; naming it here would invert that edge. The goal surface drops its own try/catch plus the structural probe that guessed whether a thrown cause was an RPC failure, and reads the result instead. --- packages/api/gateway/src/client/index.ts | 43 ++-- packages/api/gateway/tests/client.spec.ts | 214 +++++++++++------- packages/client/ui-goal/src/client/index.ts | 37 +-- packages/typert/generator/src/emitter.ts | 9 +- .../generator/tests/remote-model.spec.ts | 58 ++++- packages/typert/type-meta/src/index.ts | 2 + packages/typert/type-meta/src/types.ts | 24 ++ 7 files changed, 262 insertions(+), 125 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 745984a609..9c7e15d1fc 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -6,10 +6,11 @@ import { Service } from '@deepseek-ai/cordis' import type { Context, Events } from '@deepseek-ai/cordis' -import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, TypeRTClientRemote, + RemoteResult, TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, @@ -328,7 +329,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise { + ): Promise> { if (scoped !== undefined) { const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) const identity = binder?.identity(callerCtx) @@ -359,9 +360,9 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): Promise { + ): Promise> { const endpoint = endpointOf(descriptor) - if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) + if (!token.active) return withdrawn(endpoint) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 if (values.length !== expected && !hasCallerSignal) { @@ -391,7 +392,8 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { let valueIndex = 0 descriptor.parameters.forEach((parameter, parameterIndex) => { if (parameterIndex === projection?.parameterIndex) return - args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + if (value !== undefined) args[parameter.wire] = value valueIndex += 1 }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined @@ -400,10 +402,16 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { const signal = callerSignal === undefined ? token.abort.signal : AbortSignal.any([token.abort.signal, callerSignal]) - const result = await connection.rpc.call('/api', endpoint, { args }, signal) - if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) - if (!result.ok) throw remoteFailure(endpoint, result.error) - return parse(descriptor.result, result.value, endpoint, 'result') + try { + const result = await connection.rpc.call('/api', endpoint, { args }, signal) + if (!mountActive(token)) return withdrawn(endpoint) + if (!result.ok) return { ok: false, error: result.error } + return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') } + } catch (error) { + // Carrier throws (offline, abort, a rejected result payload) are outcomes + // of the call, not assembly faults, so they join the same error branch. + return carrierFailure(endpoint, error) + } } } @@ -412,7 +420,7 @@ type InvokeRemote = ( scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise +) => Promise> class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -467,7 +475,7 @@ class RemoteNamespaceService extends Service { Object.defineProperty(this, method, { configurable: true, enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise> { const callerCtx = this.ctx const current = this.methods.get(method) const direct = current?.direct @@ -566,6 +574,15 @@ function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: stri } } -function remoteFailure(endpoint: string, error: RpcError): Error { - return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error }) +/** The namespace retired before or during the call, so no request outcome exists. */ +function withdrawn(endpoint: string): RemoteResult { + return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) +} + +function carrierFailure(endpoint: string, error: unknown): RemoteResult { + return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) +} + +function internalFailure(message: string): RemoteResult { + return { ok: false, error: { code: 'internal', message, details: {} } } } diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index d4a5965611..cefdd94023 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -42,23 +42,24 @@ declare module '@deepseek-ai/dsh-type-meta' { } interface TypeRTRemoteMap { - 'goals/create': ( + 'probe/create': ( agentId: string, request: { readonly objective: string }, signal?: AbortSignal, ) => Promise<{ readonly ref: string }> + 'probe/maybe': (value: string | null | undefined) => Promise } interface TypeRTRemoteScopeMap { - 'fixture:goals/create': ( + 'fixture:probe/create': ( request: { readonly objective: string }, signal?: AbortSignal, ) => Promise<{ readonly ref: string }> - 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + 'fixture:probe/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> } interface TypeRTRemoteNamespaceMap { - goals: TypeRTRemoteNamespace<'goals'> + probe: TypeRTRemoteNamespace<'probe'> } } @@ -87,9 +88,9 @@ const renameResultSchema = z.object({ renamed: z.boolean() }) function directDescriptor(): InvocationDescriptor { return { - id: '@fixture/goals#goals/create', - service: 'goals', - namespace: 'goals', + id: '@fixture/probe#probe/create', + service: 'probe', + namespace: 'probe', method: 'create', invocation: { kind: 'direct' }, scope: { context: 'fixture', wire: 'agentId' }, @@ -112,9 +113,9 @@ function directDescriptor(): InvocationDescriptor { function contextDescriptor(): InvocationDescriptor { return { - id: '@fixture/goals#goals/rename', - service: 'goals', - namespace: 'goals', + id: '@fixture/probe#probe/rename', + service: 'probe', + namespace: 'probe', method: 'rename', invocation: { kind: 'context', @@ -132,6 +133,25 @@ function contextDescriptor(): InvocationDescriptor { } } +function maybeDescriptor(): InvocationDescriptor { + const schema = z.union([z.string(), z.null(), z.undefined()]) + return { + id: '@fixture/probe#probe/maybe', + service: 'probe', + namespace: 'probe', + method: 'maybe', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + acceptsUndefined: true, + codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema }, + } +} + async function bench(call: ConnectionHandle['rpc']['call']): Promise { const { ctx } = await benchFiber(call) return ctx @@ -153,24 +173,24 @@ describe('Client TypeRT API', () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) - const businessGoals = { owner: 'host business service' } - const disposeBusinessGoals = ctx.provide('goals', businessGoals) + const businessProbe = { owner: 'host business service' } + const disposeBusinessProbe = ctx.provide('probe', businessProbe) const assembly = ctx.plugin(Object.assign( - (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), { inject: ['remote'] }, )) await assembly - const retained = ctx.remote.goals.create + const retained = ctx.remote.probe.create - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( '/api', - 'goals/create', + 'probe/create', { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), ) const callerAbort = new AbortController() - await expect(ctx.remote.goals.create( + await expect(ctx.remote.probe.create( 'agent-1', { objective: 'cancel me' }, callerAbort.signal, @@ -182,18 +202,52 @@ describe('Client TypeRT API', () => { callerAbort.abort(cancellation) expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.reason).toBe(cancellation) - await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await assembly.dispose() - expect((ctx.remote as unknown as Record).goals).toBeUndefined() - expect(ctx.get('remote.goals')).toBeUndefined() - expect(ctx.get('goals')).toBe(businessGoals) + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() + expect(ctx.get('probe')).toBe(businessProbe) expect(ctx.typert.remotes.list()).toEqual([]) await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') - disposeBusinessGoals() + disposeBusinessProbe() + }) + + it('encodes declared undefined as an omitted argument and distinguishes it from null results', async () => { + const call = vi.fn() + .mockResolvedValueOnce({ ok: true, value: undefined }) + .mockResolvedValueOnce({ ok: true, value: null }) + const ctx = await bench(call) + const dispose = await ctx.remote.$mount({ + package: '@fixture/maybe', + descriptors: [maybeDescriptor()], + }) + + // The analyzers disagree on the key-remapped namespace projection: tsc + // resolves this method, oxlint reads it as an error type. + // oxlint-disable-next-line typescript/no-unsafe-call + await expect(ctx.remote.probe.maybe(undefined)).resolves.toBeUndefined() + expect(call).toHaveBeenNthCalledWith( + 1, + '/api', + 'probe/maybe', + { args: {} }, + expect.any(AbortSignal), + ) + // oxlint-disable-next-line typescript/no-unsafe-call + await expect(ctx.remote.probe.maybe(null)).resolves.toBeNull() + expect(call).toHaveBeenNthCalledWith( + 2, + '/api', + 'probe/maybe', + { args: { value: null } }, + expect.any(AbortSignal), + ) + + await dispose() }) it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { @@ -205,24 +259,24 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), { inject: ['remote'] }, )) await assembly - await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( '/api', - 'goals/create', + 'probe/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) + await expect((ctx as FixtureContext).remote.probe.create({ objective: 'wrong scope' })) .rejects.toThrow('expected 2 business argument(s)') await assembly.dispose() - expect((ctx.remote as unknown as Record).goals).toBeUndefined() - expect(ctx.get('remote.goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() }) it('uses the caller Context identity for scoped namespace methods', async () => { @@ -234,23 +288,23 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }), { inject: ['remote'] }, )) await assembly - await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.probe.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( '/api', - 'goals/rename', + 'probe/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) + await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'land' })) .rejects.toThrow('requires a "fixture" Context') await assembly.dispose() - expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() }) it('rejects weak descriptors and namespace collisions before registration', async () => { @@ -282,32 +336,32 @@ describe('Client TypeRT API', () => { await expect(ctx.remote.$mount({ package: '@fixture/direct-duplicates', - descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + descriptors: [direct, { ...direct, id: '@fixture/probe#probe/create-again' }], })).rejects.toThrow('repeats direct method') await expect(ctx.remote.$mount({ package: '@fixture/scoped-duplicates', - descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + descriptors: [context, { ...context, id: '@fixture/probe#probe/rename-again' }], })).rejects.toThrow('repeats scoped method') const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) await expect(ctx.remote.$mount({ - package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], - })).rejects.toThrow('direct method goals/create is already mounted') + package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#probe/create' }], + })).rejects.toThrow('direct method probe/create is already mounted') await disposeDirect() const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) await expect(ctx.remote.$mount({ - package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], - })).rejects.toThrow('scoped method goals/rename is already mounted') + package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#probe/rename' }], + })).rejects.toThrow('scoped method probe/rename is already mounted') await expect(ctx.remote.$mount({ package: '@fixture/service-method-conflict', - descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], + descriptors: [{ ...context, id: '@fixture/probe#probe/remove', method: 'remove' }], })).rejects.toThrow('conflicts with its namespace service') - const scopedService = ctx.get('remote.goals') as unknown as object + const scopedService = ctx.get('remote.probe') as unknown as object Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) await expect(ctx.remote.$mount({ package: '@fixture/service-own-property-conflict', - descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], + descriptors: [{ ...direct, id: '@fixture/probe#probe/custom', method: 'custom' }], })).rejects.toThrow('conflicts with its namespace service') Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() @@ -323,10 +377,10 @@ describe('Client TypeRT API', () => { package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.probe.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenLastCalledWith( '/api', - 'goals/rename', + 'probe/rename', { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } }, expect.any(AbortSignal), ) @@ -338,7 +392,7 @@ describe('Client TypeRT API', () => { const { scope: _scope, ...first } = directDescriptor() const second: InvocationDescriptor = { ...first, - id: '@fixture/goals#goals/archive', + id: '@fixture/probe#probe/archive', method: 'archive', } const defineProperty = Object.defineProperty @@ -353,11 +407,11 @@ describe('Client TypeRT API', () => { spy.mockRestore() } - expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) - expect(ctx.remote.goals.create).toBeTypeOf('function') - expect((ctx.remote.goals as unknown as Record).archive).toBeTypeOf('function') + expect(ctx.remote.probe.create).toBeTypeOf('function') + expect((ctx.remote.probe as unknown as Record).archive).toBeTypeOf('function') await retry() }) @@ -367,7 +421,7 @@ describe('Client TypeRT API', () => { package: '@fixture/context-anchor', descriptors: [contextDescriptor()], }) - const namespace = ctx.get('remote.goals') as unknown as { + const namespace = ctx.get('remote.probe') as unknown as { installScoped: (...args: unknown[]) => void readonly create?: unknown } @@ -429,28 +483,28 @@ describe('Client TypeRT API', () => { const ctx = await bench(call) const descriptor = directDescriptor() const dispose = await ctx.remote.$mount({ - package: '@fixture/goals', + package: '@fixture/probe', descriptors: [descriptor, contextDescriptor()], }) - const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise - const goals = (ctx as FixtureContext).remote.goals - const rename = goals.rename as unknown as (...args: unknown[]) => Promise + const create = ctx.remote.probe.create as unknown as (...args: unknown[]) => Promise + const probe = (ctx as FixtureContext).remote.probe + const rename = probe.rename as unknown as (...args: unknown[]) => Promise await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) .rejects.toThrow('got 4') - await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') - await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) + await expect(rename.call(probe)).rejects.toThrow('expected 1 argument(s), got 0') + await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' })) .rejects.toThrow('expected 2 business argument(s)') - await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) + await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ctx.set('connection', undefined) - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await dispose() }) @@ -464,23 +518,23 @@ describe('Client TypeRT API', () => { const { scope: _scope, ...first } = directDescriptor() const second: InvocationDescriptor = { ...first, - id: '@fixture/goals#goals/archive', + id: '@fixture/probe#probe/archive', method: 'archive', } - const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) - const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) + const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] }) + const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) await expect(invocation).rejects.toThrow('withdrawn during invocation') - expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) - const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - const namespace = ctx.get('remote.goals') as unknown as object + const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + const namespace = ctx.get('remote.probe') as unknown as object const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) await dispose() @@ -498,7 +552,7 @@ describe('Client TypeRT API', () => { const { scope: _scope, ...base } = directDescriptor() const descriptor: InvocationDescriptor = { ...base, - id: '@fixture/goals#goals/prototype', + id: '@fixture/probe#probe/prototype', method: 'prototype', parameters: [{ name: 'value', @@ -509,7 +563,7 @@ describe('Client TypeRT API', () => { } const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) - const method = (ctx.remote.goals as unknown as Record Promise>).prototype + const method = (ctx.remote.probe as unknown as Record Promise>).prototype await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() @@ -526,15 +580,15 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + await expect(ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })) .rejects.toThrow('fixture namespace startup failure') await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) } finally { spy.mockRestore() } - const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) - expect(ctx.remote.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/probe-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.probe.create).toBeTypeOf('function') await retry() }) @@ -554,13 +608,13 @@ describe('Client TypeRT API', () => { spy.mockRestore() } - expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) const retry = await ctx.remote.$mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()], }) - expect(ctx.remote.goals.create).toBeTypeOf('function') + expect(ctx.remote.probe.create).toBeTypeOf('function') await retry() }) @@ -578,35 +632,35 @@ describe('Client TypeRT API', () => { spy.mockRestore() } - expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) - expect((ctx.get('remote.goals') as unknown as Record).rename).toBeTypeOf('function') + expect((ctx.get('remote.probe') as unknown as Record).rename).toBeTypeOf('function') await retry() }) it('unregisters an empty scoped namespace so another provider can claim its name', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) - expect(ctx.get('remote.goals')).toBeDefined() + expect(ctx.get('remote.probe')).toBeDefined() await dispose() - expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() const replacement = { owner: 'replacement' } - const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) - expect(ctx.get('remote.goals')).toBe(replacement) + const disposeReplacement = ctx.reflect.provide('remote.probe', replacement) + expect(ctx.get('remote.probe')).toBe(replacement) await disposeReplacement() }) it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) - await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) let failure: unknown try { - await ctx.remote.goals.create('agent-1', { objective: 'ship' }) + await ctx.remote.probe.create('agent-1', { objective: 'ship' }) } catch (error) { failure = error } diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index d66e025ffd..f2f545d3c3 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -8,6 +8,7 @@ * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -40,29 +41,11 @@ const NS = 'goal' /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] -/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ -async function settle(invoke: () => Promise): Promise { - try { - await invoke() - return { ok: true } - } catch (error) { - const cause = error instanceof Error ? error.cause : undefined - if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } } - return { - ok: false, - error: { - code: 'internal', - message: error instanceof Error ? error.message : 'goal mutation failed', - }, - } - } -} - -function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } { - return value !== null - && typeof value === 'object' - && typeof (value as { code?: unknown }).code === 'string' - && typeof (value as { message?: unknown }).message === 'string' +/** Narrow one Remote mutation's result to the fields the goal strip renders. */ +function settle(result: RemoteResult): GoalActionResult { + return result.ok + ? { ok: true } + : { ok: false, error: { code: result.error.code, message: result.error.message } } } /** @@ -103,22 +86,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) + return settle(await ctx.remote.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.pause(sessionId, ref)) + return settle(await ctx.remote.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.resume(sessionId, ref)) + return settle(await ctx.remote.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.clear(sessionId, ref)) + return settle(await ctx.remote.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index cbed0047c3..03dcc30d36 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -308,6 +308,7 @@ export class FaceModelEmitter { lines.push(` wire: ${quote(parameter.wire)},`) lines.push(` source: ${quote(parameter.source)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,') lines.push(` codec: ${indent(strictCodec( parameter.boundary, schemas.boundary(parameterBoundaryKey(invocation, index)), @@ -342,6 +343,7 @@ export class FaceModelEmitter { const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', + ' RemoteResult,', ' TypeRTRemoteContribution,', '} from \'@deepseek-ai/dsh-type-meta\'', ] @@ -462,10 +464,13 @@ export class FaceModelEmitter { ): string { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => - `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + `${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) - return `(${parameters.join(', ')}) => Promise<${result}>` + // The Client Remote face delivers the carrier's outcome, so every generated + // consumer signature resolves to a result the caller reads instead of a + // value it must guard with its own try/catch. + return `(${parameters.join(', ')}) => Promise>` } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 3faef9c8be..567feaae4e 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -23,6 +23,7 @@ interface RuntimeDescriptor { readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string + readonly acceptsUndefined?: true readonly codec: { readonly schema: RuntimeSchema } }[] readonly result: { readonly schema: RuntimeSchema } @@ -146,6 +147,57 @@ describe('Remote model generation', { timeout: 60_000 }, () => { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) }) + it('projects authored optionality and absence onto consumers and codecs', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', + ` + + @Remote + maybe(value: string | undefined): string | undefined { + return value + } + + @Remote + labelled(id: string, label?: string): string { + return label ?? id + } + + @Remote + clear(): void {} +} + +export type {`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/maybe': (value: string | undefined) => Promise", + ) + expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise") + // An explicit `T | undefined` stays a required argument; only authored + // optionality lets a consumer omit the field. + expect(artifact?.remote?.dts).not.toContain('value?: string') + expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise") + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe')) + const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear')) + expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true) + expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(maybe?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(null).success).toBe(false) + const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled')) + expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined() + expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false) + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -436,9 +488,9 @@ export interface ClientMarker { message: 'Remote parameters cannot have default values', }, { - name: 'optional parameter', - edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), - message: 'Remote parameters cannot be optional', + name: 'optional lookup parameter', + edit: (source: string) => source.replace('agent: Agent,', 'agent?: Agent,'), + message: 'lookup parameter for agent cannot be optional', }, { name: 'wrong cancellation type', diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3becc67c83..7bbf3f107a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,8 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + RemoteFailure, + RemoteResult, TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index c08fb209ee..9fffb8a468 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -39,6 +39,28 @@ export interface TypeRTContextMap {} /** Merge-extensible direct Remote method signatures generated for consumers. */ export interface TypeRTRemoteMap {} +/** + * One Remote call's failure as the carrier reported it. `code` stays open here: + * the closed RPC code union belongs to the carrier package, which already + * depends on this one, so naming it would invert that edge. + */ +export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** + * What every generated Remote method resolves to. The Remote face itself folds + * carrier failures into the error branch, so no consumer wraps a call to + * recover one; only assembly faults (arity, an unmounted method, a missing + * Context binder) still reject. + * @template T - the Host method's business result. + */ +export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypeRTRemoteScopeMap {} @@ -136,6 +158,8 @@ export interface InvocationParameterDescriptor { readonly lookup?: string /** Boundary codec for the wire representation. */ readonly codec: TypeRTCodec + /** Missing wire fields decode to `undefined` only for an explicitly declared `T | undefined`. */ + readonly acceptsUndefined?: true } /** Source position retained for diagnostics from generated definitions. */ From 070a2a7f1e3520e899e0ac3dfb7ae5c4a82829e2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:10:31 +0800 Subject: [PATCH 20/46] refactor(commands): move the command service to Remote `CommandService.list` and `execute` carry the wire contract directly through `@Remote`, and the Client assembly mounts the generated commands contribution. The legacy API Proxy route, its schemas, the map rows, the generated client methods and the fixture's command domain are removed, so the catalog and the admission call have one owner again. `Session.command()` keeps a result-shaped public face for parity with the prompt, cancel and attachment neighbours it sits beside, and reads the generated namespace through one `SessionRemotes` parameter. The Session cluster declares that face against the owning business package rather than the generated contribution: the Host compiler aggregate builds this package, and it runs before any contribution is emitted. Migrated calls lose the `title-invalid` class of protocol-only error codes and report `internal`; no production caller branched on them. --- packages/api/remotes/src/client/index.ts | 33 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 211 ++++----- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 12 +- .../connection/tests/fixture-commands.spec.ts | 86 ++-- packages/client/runtime/package.json | 12 +- .../client/runtime/src/client/agents/scope.ts | 2 +- .../src/client/contract/conversation.ts | 2 +- .../runtime/src/client/contract/session.ts | 7 +- .../src/client/contract/sessions-port.ts | 2 +- .../runtime/src/client/contract/sessions.ts | 2 +- .../runtime/src/client/contract/workspaces.ts | 2 +- packages/client/runtime/src/client/index.ts | 8 +- .../src/client/sessions/conversation.ts | 2 +- .../runtime/src/client/sessions/lineage.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 6 +- .../runtime/src/client/sessions/pending.ts | 2 +- .../src/client/sessions/queue-mirror.ts | 2 +- .../runtime/src/client/sessions/remotes.ts | 12 + .../runtime/src/client/sessions/service.ts | 6 +- .../runtime/src/client/sessions/session.ts | 16 +- .../src/client/sessions/subagent-lineage.ts | 2 +- .../runtime/src/client/workspaces/manager.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 2 +- .../src/client/workspaces/workspace.ts | 2 +- .../client/runtime/tests/client-apply.spec.ts | 4 +- .../tests/conversation-registry.spec.ts | 6 +- .../client/runtime/tests/conversation.spec.ts | 2 +- packages/client/runtime/tests/fake-api.ts | 29 +- packages/client/runtime/tests/lineage.spec.ts | 2 +- packages/client/runtime/tests/manager.spec.ts | 116 ++--- packages/client/runtime/tests/partial.spec.ts | 2 +- .../runtime/tests/projection-store.spec.ts | 18 +- .../client/runtime/tests/queue-store.spec.ts | 12 +- packages/client/runtime/tests/scope.spec.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 14 +- .../runtime/tests/sessions-service.spec.ts | 6 +- .../client/runtime/tests/wire-events.spec.ts | 2 +- .../runtime/tests/workspaces-service.spec.ts | 26 +- packages/client/runtime/tsconfig.json | 5 +- packages/client/ui-command/package.json | 9 +- .../client/ui-command/src/client/directory.ts | 9 +- .../client/ui-command/src/client/index.ts | 4 +- .../client/ui-command/src/client/service.ts | 16 +- .../ui-command/tests/browser-plugin.spec.ts | 9 +- .../client/ui-command/tests/directory.spec.ts | 2 +- .../client/ui-command/tests/service.spec.ts | 63 ++- packages/client/ui-command/tsconfig.json | 7 +- .../tests/input-scenarios.spec.tsx | 4 +- packages/client/ui-conversation/tsconfig.json | 3 - packages/client/ui-plan/package.json | 6 +- packages/client/ui-plan/src/client/index.ts | 11 +- .../ui-plan/tests/browser-plugin.spec.ts | 28 +- packages/client/ui-plan/tsconfig.json | 6 +- packages/host/apiproxy/src/api-proxy.ts | 45 +- .../host/apiproxy/src/api/commands.schema.ts | 44 -- packages/host/apiproxy/src/api/commands.ts | 50 -- packages/host/apiproxy/src/api/index.ts | 3 - packages/host/apiproxy/src/api/rpc-map.ts | 3 - packages/host/apiproxy/src/fetch/client.ts | 16 - packages/host/apiproxy/src/fetch/handler.ts | 3 - packages/host/apiproxy/src/index.ts | 2 - .../apiproxy/tests/api-proxy-commands.spec.ts | 427 ------------------ .../apiproxy/tests/client-handler.spec.ts | 6 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 50 +- packages/interaction/commands/package.json | 17 +- packages/interaction/commands/src/index.ts | 54 +-- packages/interaction/commands/src/types.ts | 39 ++ packages/interaction/commands/tsconfig.json | 3 + pnpm-lock.yaml | 153 +++++-- 71 files changed, 648 insertions(+), 1129 deletions(-) create mode 100644 packages/client/runtime/src/client/sessions/remotes.ts delete mode 100644 packages/host/apiproxy/src/api/commands.schema.ts delete mode 100644 packages/host/apiproxy/src/api/commands.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-commands.spec.ts diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 9be953c6c6..7cc59cffb0 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,10 +1,12 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from '@deepseek-ai/cordis' +import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' +export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails. @@ -17,12 +19,22 @@ export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' + /** - * The Gateway Client face's own declaration merges, type-only: `ctx.remote` and - * with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still - * carries no runtime edge to the Gateway implementation. + * The carrier's Client-facing types, re-exported so a business package names one + * assembly package instead of both this facade and the Connection plugin. Type-only: + * the carrier's runtime values stay behind their own module edge. */ -export type {} from '@deepseek-ai/dsh-api-gateway/client' +export type { + ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, + CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient, + MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, + RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, + SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, + SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView, + WorkspaceId, WorkspaceView +} from '@deepseek-ai/dsh-client-connection/client' declare module '@deepseek-ai/cordis' { interface Context { @@ -40,5 +52,16 @@ export const inject = ['remote'] * @returns disposer after every selected Remote namespace is ready. */ export async function apply(ctx: Context): Promise<() => Promise> { - return await ctx.remote.$mount(goalsRemote) + const disposers: Array<() => Promise> = [] + try { + for (const contribution of [commandsRemote, goalsRemote]) { + disposers.push(await ctx.remote.$mount(contribution)) + } + } catch (error) { + for (const dispose of disposers.reverse()) await dispose() + throw error + } + return async () => { + for (const dispose of disposers.reverse()) await dispose() + } } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 980a2f02a8..2eca23399a 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,7 +10,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, - CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, + SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 447b46340a..fdd45120a4 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -28,6 +28,7 @@ import type { // Type-only: the brand constructor is host-side; the fixture casts at its // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, @@ -1379,11 +1380,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return createFixtureWorld(options).api } -interface FixtureWorld { +/** Both fixture faces over one state graph. */ +export interface FixtureWorld { + /** Legacy unary/stream API the fixture still answers. */ readonly api: ApiProxy + /** Generic Remote caller for the endpoints business services own. */ readonly rpc: ClientConnectionRpc } +/** + * Build both fixture faces so a caller can drive the Remote endpoints and the + * legacy API against one in-memory state graph. + * @param options - fixture branches for empty state and failure timing. + * @returns the legacy API face and the Remote RPC face. + */ +export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { + return createFixtureWorld(options) +} + /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. @@ -1598,6 +1612,98 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : undefined ) + /** Canonical fixture implementation of the generated Commands Remote contract. */ + const commandRemotes = { + list(id: SessionId): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + return { + ok: true, + value: [ + { name: 'compact', description: 'fixture:压缩当前会话上下文' }, + { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, + ], + } + }, + execute(id: SessionId, line: string): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + // Structured split mirroring the Host parser: name + verbatim rawInput + // (separator whitespace included) — the run payload carries no line. + const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim()) + const name = match?.[1] + const args = match?.[2] ?? '' + if (name === 'permission') { + const preset = args.trim() + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const spec = PERMISSION_PRESETS[preset] + let result: CommandResult + if (preset === '') { + const current = permissionSelectOf(logOf(id)).currentValue + result = { kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } + } else if (spec === undefined) { + result = { kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } + } else { + if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) + append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) + append(id, { type: 'approval/policy', data: { policy: spec.approval } }) + result = { kind: 'success', text: `preset ${preset}` } + } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } + if (name === 'goal') { + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const objective = args.trim() + const current = backscanGoal(logOf(id)) + let text: string + if (objective === '') { + text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` + } else if (current !== null && current.goal.phase !== 'complete') { + text = `A goal already exists (${current.goal.objective}). Clear it first.` + } else { + const created = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, + roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), + }) + text = `Goal created: ${created.goal.objective}` + } + const result: CommandResult = { kind: 'success', text } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + } + const running = summaryOf(id)?.running === true + const outcomes: Record = { + compact: 'fixture:已压缩(假动作)', + echo: args.trim(), + plan: args.trim() === 'off' + ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') + : (running + ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' + : 'Plan mode on. Use /plan off to leave.'), + } + const text = name === undefined ? undefined : outcomes[name] + if (name === undefined || text === undefined) return { ok: true, value: undefined } + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + if (name === 'plan' && !running) { + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + } + const result: CommandResult = { kind: 'success', ...text === '' ? {} : { text } } + append(id, { type: 'command/done', data: { commandId, ...result } }) + return { ok: true, value: { commandId, result } } + }, + } + const goalView = (projection: FxGoalProjection): FxGoalView => ({ ...projection.goal, roundsStarted: projection.roundsStarted, @@ -2446,104 +2552,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { archivedSessionIds: [...archivedSessionIds] }) }, }, - commands: { - // The catalog mirrors one session's effective view (every fixture - // session has an agent, like the real host). - list: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - return ok(request, { - commands: [ - { name: 'compact', description: 'fixture:压缩当前会话上下文' }, - { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, - { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, - ], - }) - }, - // Pure admission, mirroring the host: an admitted command logs the - // command/run + command/done lifecycle pair (mux-broadcast by append), - // and the response only reports resolution. - execute: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - // Structured split mirroring the host parser: name + verbatim rawInput - // (separator whitespace included) — the run payload carries no line. - const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) - const name = match?.[1] - const args = match?.[2] ?? '' - // /permission mirrors the host handler: switch through the knob - // events (each append pushes a permissions projection frame). - if (name === 'permission') { - const preset = args.trim() - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const spec = PERMISSION_PRESETS[preset] - if (preset === '') { - const current = permissionSelectOf(logOf(id)).currentValue - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) - } else if (spec === undefined) { - append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) - } else { - if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) - append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) - append(id, { type: 'approval/policy', data: { policy: spec.approval } }) - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } }) - } - return ok(request, { matched: true as const, commandId }) - } - if (name === 'goal') { - // Host parallel: /goal with an objective creates (or reports) the - // current goal; the command lifecycle pair brackets the mutation. - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const objective = args.trim() - const current = backscanGoal(logOf(id)) - let text: string - if (objective === '') { - text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` - } else if (current !== null && current.goal.phase !== 'complete') { - text = `A goal already exists (${current.goal.objective}). Clear it first.` - } else { - const created = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, - roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), - }) - text = `Goal created: ${created.goal.objective}` - } - append(id, { type: 'command/done', data: { commandId, kind: 'success', text } }) - return ok(request, { matched: true as const, commandId }) - } - // Host parallel: /plan on an idle fixture session commits plan/mode - // immediately (the boundary flush covers only a running turn), so the - // outcome copy matches the immediate branch of the host handler. - const running = summaryOf(id)?.running === true - const outcomes: Record = { - compact: 'fixture:已压缩(假动作)', - echo: args.trim(), - plan: args.trim() === 'off' - ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') - : (running - ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' - : 'Plan mode on. Use /plan off to leave.'), - } - const text = name === undefined ? undefined : outcomes[name] - if (name === undefined || text === undefined) return ok(request, { matched: false as const }) - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - if (name === 'plan' && !running) { - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) - } - } - append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } }) - return ok(request, { matched: true as const, commandId }) - }, - }, agentPresets: { // Both trusts appear, because a surface must present a locally authored // preset differently from one the deployment vetted. @@ -2852,12 +2860,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const args = (payload as { args: { agentId: SessionId + line?: string ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } } }).args const sessionId = args.agentId switch (endpoint) { + case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) + case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string)) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, @@ -2950,8 +2961,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) - case 'command.list': return this.api.commands.list(request) - case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index dadcaf5538..1b909e380a 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -18,7 +18,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, - CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, + SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index ff35b4d892..d2cec5ed54 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -1,9 +1,8 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, + HostFrame, IApiClient, ModelSelection, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -169,19 +168,10 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - readonly commands: IApiClient['commands'] = { - list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), - execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), - } - readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index 70ee127b3d..909d0baf46 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -1,28 +1,35 @@ /** - * Fixture commands/skills domains: contract-shape conformance for the two - * domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute - * parse/dispatch, skill.list session resolution, and the FixtureApiClient - * dispatch rows. + * Fixture commands/skills domains: session-addressed catalogs, execute + * parse/dispatch and its logged lifecycle pair, skill.list session resolution, + * and the FixtureApiClient dispatch rows. Commands answer on the Remote face + * and skills on the legacy API face, so both are driven here. */ import { describe, expect, it } from 'vitest' import type { SessionId } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' import type { RpcRequest } from '../src/client/api.ts' -import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' +import { FixtureApiClient, createFixtureApi, createFixtureFaces } from '../src/client/fixture.ts' + +/** Drive one commands Remote endpoint against the fixture state graph. */ +async function callRemote( + rpc: ReturnType['rpc'], + endpoint: string, + args: Record, +): Promise { + const result = await rpc.call('/api', endpoint, { args }) + if (!result.ok) throw new Error(`${endpoint} failed: ${result.error.code}`) + return result.value as T +} const sid = (id: string): SessionId => id as SessionId let reqCount = 0 const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${reqCount++}`), payload }) -const signal = new AbortController().signal describe('createFixtureApi commands/skills', () => { - it('serves the addressed session catalog with rpcId echo', async () => { - const api = createFixtureApi() - const request = req({ sessionId: sid('fx-alpha') }) - const response = await api.commands.list(request) - expect(response.rpcId).toBe(request.rpcId) - if (!response.result.ok) throw new Error('list failed') - const commands = response.result.value.commands + it('serves the addressed session catalog', async () => { + const { rpc } = createFixtureFaces() + const commands = await callRemote<{ name: string; input?: { hint: string } }[]>( + rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') @@ -31,13 +38,13 @@ describe('createFixtureApi commands/skills', () => { }) it('rejects a catalog request for an unknown session', async () => { - const api = createFixtureApi() - const response = await api.commands.list(req({ sessionId: sid('fx-nope') })) - expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + const { rpc } = createFixtureFaces() + const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => { - const api = createFixtureApi() + const { api, rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() const stream = api.events.mux(req({}), abort.signal) @@ -47,10 +54,9 @@ describe('createFixtureApi commands/skills', () => { if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() } })() - const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal) - if (!response.result.ok) throw new Error('execute failed') - expect(response.result.value).toMatchObject({ matched: true }) - expect(response.result.value.commandId).toBeTruthy() + const execution = await callRemote<{ commandId: string } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' }) + expect(execution?.commandId).toBeTruthy() await pump const events = frames .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') @@ -63,22 +69,23 @@ describe('createFixtureApi commands/skills', () => { }) it('addresses execute to the session; an unknown session errs', async () => { - const api = createFixtureApi() - const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal) - if (!hit.result.ok) throw new Error('execute failed') - expect(hit.result.value.matched).toBe(true) + const { rpc } = createFixtureFaces() + const hit = await callRemote<{ commandId: string } | undefined>( + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' }) + expect(hit?.commandId).toBeTruthy() - const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + const missing = await rpc.call('/api', 'commands/execute', { + args: { agentId: sid('fx-nope'), line: '/goal ship' }, + }) + expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) - it('falls to matched:false on unknown names and non-command lines', async () => { - const api = createFixtureApi() + it('answers no execution for unknown names and non-command lines', async () => { + const { rpc } = createFixtureFaces() for (const line of ['/nope', 'plain text', '/']) { - const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal) - if (!response.result.ok) throw new Error('execute failed') - // Pure admission value: the matched bit is the whole response shape. - expect(response.result.value).toEqual({ matched: false }) + // Absence is the whole answer: nothing matched, so no lifecycle id exists. + expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line })) + .toBeUndefined() } }) @@ -94,14 +101,13 @@ describe('createFixtureApi commands/skills', () => { }) describe('FixtureApiClient command/skill dispatch', () => { - it('routes the three method keys through the in-memory dispatch table', async () => { + it('routes the Remote commands face and the legacy skill row through one state graph', async () => { const client = new FixtureApiClient() - const list = await client.commands.list({ sessionId: sid('fx-alpha') }) - if (!list.result.ok) throw new Error('command.list failed') - expect(list.result.value.commands.length).toBeGreaterThan(0) - const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' }) - if (!executed.result.ok) throw new Error('command.execute failed') - expect(executed.result.value.matched).toBe(true) + const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') }) + expect(commands.length).toBeGreaterThan(0) + const executed = await callRemote<{ commandId: string } | undefined>( + client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) + expect(executed?.commandId).toBeTruthy() const skills = await client.skills.list({ sessionId: sid('fx-alpha') }) if (!skills.result.ok) throw new Error('skill.list failed') expect(skills.result.value.skills.length).toBeGreaterThan(0) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index be2bfa4172..d4d65c8c8b 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -34,7 +34,7 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-typert-registry", - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-api-remotes" ], "platform": "web", "immediately": true @@ -59,19 +59,19 @@ "zustand": "~4.4.7" }, "peerDependencies": { - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1" }, "files": [ diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index b32840daa0..d2bf7d074c 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -17,7 +17,7 @@ */ import { Context as CordisContext } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 26e7e43c67..f14fb02d7c 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client' /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 95bff54915..574b7c9f6a 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -10,7 +10,8 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId, PromptContentPart, QueueAction, RpcResult, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ObservableSnapshot } from './store.ts' @@ -75,9 +76,9 @@ export interface ISession { * Execute one slash-command line against this session's agent — pure * admission semantics (the host executor durably logs the lifecycle). * @param line - the full command line, leading slash included. - * @returns the admission result, or the error branch on transport failure. + * @returns the admission result, or the Remote face's error branch. */ - command(line: string): Promise> + command(line: string): Promise> } /** diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 466e26fe12..0eec0f5f86 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -7,7 +7,7 @@ * dependency. */ -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-api-remotes/client' import type { ObservableSnapshot } from './store.ts' /** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */ diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 960d2038de..27b9d2d13b 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -10,7 +10,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { RpcResult, SessionId, SubagentAddress, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentContext } from '../agents/scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index ad896bbdaf..8441de8eb0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -6,7 +6,7 @@ * the concrete class. Widening this interface is the explicit act of * widening what features may do to the workspaces domain. */ -import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { WorkspaceListState } from '../workspaces/service.ts' import type { ObservableSnapshot } from './store.ts' diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 4f80cd14e6..1b176254b9 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,10 +1,10 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather // than api-remotes': that face imports a Host-tsdown-generated artifact, and this // project sits in the Host build graph. -import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' @@ -179,7 +179,7 @@ declare module '@deepseek-ai/cordis' { } /** Required services: the wire handle and Client TypeRT registry. */ -export const inject = ['connection', 'typert', 'remote'] +export const inject = ['connection', 'typert', 'remote', 'remote.commands'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -191,7 +191,7 @@ export function apply(ctx: Context): void { views: new ConversationViewRegistry(ctx), } const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionsService(ctx, connection.api, conversation) + const sessions = new SessionsService(ctx, connection.api, ctx.remote, conversation) ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), }) diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 7972ce5e50..f39156baf2 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -13,7 +13,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' import type { diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index cf8fa0834d..7579310f49 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -2,7 +2,7 @@ // The input order is authoritative; lineage only makes each child adjacent to its parent. // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { PendingInteractionStatus } from './pending.ts' diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index acb56fbde0..3cc46843fc 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -5,7 +5,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-session-title/client' import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' +import type { SessionRemotes } from './remotes.ts' /** * List arrival lifecycle, orthogonal to the pull-activity `state` axis: @@ -164,6 +165,7 @@ export class SessionManager { */ constructor( private readonly api: IApiClient, + private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, private readonly conversation?: ConversationRuntime, @@ -304,7 +306,7 @@ export class SessionManager { private createSession(sessionId: SessionId): Session { const address = this.addresses.get(sessionId) - return new Session(sessionId, this.api, { + return new Session(sessionId, this.api, this.remote, { ...(address === undefined ? {} : { address, parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index 1383faea35..b8b3492a04 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -4,7 +4,7 @@ import type { ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' /** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ export interface PendingPayloads { diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/client/runtime/src/client/sessions/queue-mirror.ts index be7351cf5f..1eb4e6fdbe 100644 --- a/packages/client/runtime/src/client/sessions/queue-mirror.ts +++ b/packages/client/runtime/src/client/sessions/queue-mirror.ts @@ -1,5 +1,5 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client' +import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { QueuedMessage } from './conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/remotes.ts b/packages/client/runtime/src/client/sessions/remotes.ts new file mode 100644 index 0000000000..4fa503345b --- /dev/null +++ b/packages/client/runtime/src/client/sessions/remotes.ts @@ -0,0 +1,12 @@ +/** + * Remote namespaces the Session cluster calls. One parameter for one concept: + * the generated surface a Session and its manager reach the Host through. + * + * @module @deepseek-ai/dsh-client-runtime/client/sessions/remotes + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' + +/** The generated Remote namespaces a Session and its manager call. */ +export type SessionRemotes = Pick diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b7267402e..23438554ea 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -17,7 +17,7 @@ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import type { ConversationRuntime } from './conversation-assembler.ts' import { SessionManager } from './manager.ts' +import type { SessionRemotes } from './remotes.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { PendingInteractionStatus } from './pending.ts' import { SessionProvideChannel } from './provide.ts' @@ -271,11 +272,13 @@ export class SessionsService implements ISessions { /** * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. + * @param remote - generated Remote namespaces shared with every Session. * @param conversationRuntime - same-pass registry instances, when runtime apply owns them. */ constructor( private readonly rootCtx: Context, api: IApiClient, + remote: SessionRemotes, conversationRuntime?: ConversationRuntime, ) { this.selection = createSnapshotStore( @@ -291,6 +294,7 @@ export class SessionsService implements ISessions { ) this.manager = new SessionManager( api, + remote, restored.sessionId, restored.subagentAddress, conversation, diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 5ab20a07cd..fc50cbb499 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -21,6 +21,8 @@ import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' import { Notifier } from './notifier.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' +import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' import { resolvedClientTimeZone } from '../time-zone.ts' @@ -134,11 +136,13 @@ export class Session implements SessionFace { /** * @param sessionId - Host session identity (client sessions are always Host-born). * @param api - shared wire client. + * @param remote - generated Remote namespaces this session calls. * @param options - optional manager-owned state observers. */ constructor( readonly sessionId: SessionId, private readonly api: IApiClient, + private readonly remote: SessionRemotes, private readonly options: SessionOptions = {}, ) { this.projections = options.projections ?? new ProjectionValueStore() @@ -351,12 +355,10 @@ export class Session implements SessionFace { * @param line - the full command line, leading slash included. * @returns the admission result, or the error branch on transport failure. */ - async command(line: string): Promise> { - try { - return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result - } catch (error) { - return transportError(error) - } + async command(line: string): Promise> { + const result = await this.remote.commands.execute(this.sessionId, line) + if (!result.ok) return result + return { ok: true, value: { matched: result.value !== undefined } } } /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ diff --git a/packages/client/runtime/src/client/sessions/subagent-lineage.ts b/packages/client/runtime/src/client/sessions/subagent-lineage.ts index fb56b4eb8c..518f45ab1e 100644 --- a/packages/client/runtime/src/client/sessions/subagent-lineage.ts +++ b/packages/client/runtime/src/client/sessions/subagent-lineage.ts @@ -4,7 +4,7 @@ * uninterrupted subagent subtree. * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from './service.ts' /** Descendant counts projected for one possible parent session. */ diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index df3aa8fe28..dc618977d8 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -2,7 +2,7 @@ import type { HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { Notifier } from '../sessions/notifier.ts' diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 468ae95a19..733a894c7b 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index 6d24534c6c..a9eec992a3 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -2,7 +2,7 @@ import type { IApiClient, RpcResult, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 50812ee13c..40b7461b37 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -5,8 +5,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 5181dab926..79d63f3226 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' import type { @@ -8,7 +8,7 @@ import type { } from '../src/client/contract/conversation.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionsService } from '../src/client/sessions/service.ts' -import { FakeApiClient, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' function eventDefinition(kind: string): ConversationNodeDefinition { return { @@ -145,7 +145,7 @@ describe('Conversation registries', () => { api.onList = () => Promise.resolve(ok({ items: [{ sessionId, updatedAt: 1, running: false, blank: true }], }) as never) - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) await sessions.refresh() await Promise.resolve() sessions.scope(sessionId) diff --git a/packages/client/runtime/tests/conversation.spec.ts b/packages/client/runtime/tests/conversation.spec.ts index 345f21b598..1237f73ae8 100644 --- a/packages/client/runtime/tests/conversation.spec.ts +++ b/packages/client/runtime/tests/conversation.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client' +import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client' import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' describe('toAssistantBlock', () => { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 78d9605edd..94766c09e2 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -1,13 +1,13 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, + ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' /** Programmable-default workspace row (branded id, ISO-ish times). */ function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { @@ -55,6 +55,20 @@ interface StreamConn { feed(item: StreamItem): void } +/** + * Commands Remote double: the generated face delivers the carrier's outcome, so + * a test that programs nothing sees an empty catalog and an unmatched line. + * @returns the Remote namespaces the session cluster calls. + */ +export function fakeRemote(): SessionRemotes { + return { + commands: { + list: () => Promise.resolve({ ok: true, value: [] }), + execute: () => Promise.resolve({ ok: true, value: undefined }), + }, + } +} + export class FakeApiClient implements IApiClient { /** Chronological call record: [method, payload]. */ readonly calls: { method: string; payload: unknown }[] = [] @@ -205,19 +219,10 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program requires-bearing catalogs and dual-address // skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - readonly commands: IApiClient['commands'] = { - list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), - execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), - } - readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.spec.ts index 7d3c948f3e..01ecacd1e2 100644 --- a/packages/client/runtime/tests/lineage.spec.ts +++ b/packages/client/runtime/tests/lineage.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client' import { flattenLineage } from '../src/client/sessions/lineage.ts' const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 92d4af622e..dec157341a 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -4,9 +4,9 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId @@ -28,7 +28,7 @@ describe('instances', () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever @@ -37,7 +37,7 @@ describe('instances', () => { it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) // Uninstantiated: approval buffers, plain session/event drops. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) @@ -50,7 +50,7 @@ describe('instances', () => { it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) for (let i = 0; i < 40; i++) { manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) @@ -67,7 +67,7 @@ describe('instances', () => { }) it('drops buffered answerable requests on session removal', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) @@ -80,7 +80,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') @@ -96,7 +96,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const first = deferred>>() api.onList = () => first.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const hydration = manager.refreshList() manager.handleHostEnvelope({ rpcId: 'during-first' as never, @@ -116,7 +116,7 @@ describe('list lifecycle', () => { it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) // A failed pull does not step the arrival phase: still pending. @@ -125,7 +125,7 @@ describe('list lifecycle', () => { it('phase steps pending → ready on the first successful pull and never returns', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') @@ -144,7 +144,7 @@ describe('list lifecycle', () => { it('merges create into the list immediately without waiting for a refresh', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) @@ -152,7 +152,7 @@ describe('list lifecycle', () => { it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const titleFrame = (rpcId: string, title: string, seq: number) => { manager.handleMuxEnvelope({ rpcId: rpcId as never, @@ -179,7 +179,7 @@ describe('list lifecycle', () => { it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) // A push frame landed before the list (S2's title is newer than the block's cut). manager.handleMuxEnvelope({ rpcId: 'push-newer' as never, @@ -202,7 +202,7 @@ describe('list lifecycle', () => { it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() const frame = (rpcId: string, payload: object) => { manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never }) @@ -230,7 +230,7 @@ describe('search', () => { items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ @@ -246,7 +246,7 @@ describe('search', () => { it('preserves business errors and folds transport failures', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onSearch = () => Promise.resolve(err({ code: 'internal', message: 'index unavailable', @@ -269,7 +269,7 @@ describe('search', () => { describe('host frame routing', () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) @@ -303,7 +303,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) @@ -368,7 +368,7 @@ describe('subagent catalogs', () => { vi.useFakeTimers() try { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() @@ -418,7 +418,7 @@ describe('subagent catalogs', () => { ] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(root) manager.handleHostEnvelope({ @@ -447,7 +447,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ @@ -488,7 +488,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) manager.handleHostEnvelope({ @@ -529,7 +529,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(S1) manager.handleHostEnvelope({ @@ -547,7 +547,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) @@ -566,7 +566,7 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, root) + const manager = new SessionManager(api, fakeRemote(), root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced @@ -624,7 +624,7 @@ describe('subagent catalogs', () => { }) const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshSubagents(root) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh @@ -671,7 +671,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) @@ -690,14 +690,14 @@ describe('remaining branches', () => { it('refreshList folds a transport throw into the error state', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const session = manager.get(S1) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() @@ -707,7 +707,7 @@ describe('remaining branches', () => { it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) @@ -727,7 +727,7 @@ describe('remaining branches', () => { message: 'published but unattached', details: { sessionId: S1, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) @@ -741,7 +741,7 @@ describe('remaining branches', () => { message: 'forked but unattached', details: { sessionId: S2, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ @@ -754,7 +754,7 @@ describe('remaining branches', () => { it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(manager.getListSnapshot().items).toEqual([]) @@ -775,7 +775,7 @@ describe('remaining branches', () => { it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() @@ -790,7 +790,7 @@ describe('remaining branches', () => { it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never }) @@ -805,7 +805,7 @@ describe('remaining branches', () => { it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() const before = manager.getListSnapshot() manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) @@ -821,7 +821,7 @@ describe('remaining branches', () => { it('carries parentSessionId from host/session-added into the lineage row', () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleHostEnvelope({ rpcId: 'h2' as never, @@ -845,7 +845,7 @@ describe('connected generation', () => { hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened @@ -863,7 +863,7 @@ describe('connected generation', () => { const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } - const manager = new SessionManager(api, S2, address) + const manager = new SessionManager(api, fakeRemote(), S2, address) manager.handleConnected() @@ -876,7 +876,7 @@ describe('connected generation', () => { describe('pending-interaction list status', () => { it('tracks approval requests through replay and resolution without instantiation', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) @@ -889,7 +889,7 @@ describe('pending-interaction list status', () => { }) it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'q1' as never, @@ -922,7 +922,7 @@ describe('pending-interaction list status', () => { ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'q-plan' as never, @@ -939,7 +939,7 @@ describe('pending-interaction list status', () => { }) it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ @@ -958,7 +958,7 @@ describe('pending-interaction list status', () => { }) it('drops stale status at generation death before replay re-adds live interactions', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') @@ -973,7 +973,7 @@ describe('pending-interaction list status', () => { }) it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) // Buffered pre-instantiation: an approval pair and a queued row. manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) @@ -1000,7 +1000,7 @@ describe('completed reminder', () => { manager.getListSnapshot().items.find(item => item.sessionId === sessionId) it('arms on a running→idle flip of a non-selected session and clears on select', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S1) @@ -1014,7 +1014,7 @@ describe('completed reminder', () => { }) it('never arms for the session being watched and re-arms after a switch-away re-run', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S2) @@ -1029,7 +1029,7 @@ describe('completed reminder', () => { }) it('a re-run disarms the reminder while running and re-arms on its completion', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S1) @@ -1044,7 +1044,7 @@ describe('completed reminder', () => { }) it('session-removed drops the reminder and a re-add starts clean', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope(added('h1', S1)) manager.handleHostEnvelope(added('h2', S2)) manager.select(S1) @@ -1060,7 +1060,7 @@ describe('completed reminder', () => { it('a list refresh carrying the running→idle transition arms the reminder', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1072,7 +1072,7 @@ describe('completed reminder', () => { it('never arms for sessions already idle at first observation', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1085,7 +1085,7 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshList() // The session finishes while the first pull is still in flight; the pull // response recorded it as running at pull time. @@ -1099,7 +1099,7 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) const refresh = manager.refreshList() // The unknown session starts and finishes while the first pull is in // flight; the pull-time baseline recorded it idle, so the running→idle @@ -1120,7 +1120,7 @@ describe('background-task mirror', () => { ({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never }) it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) const first = manager.getListSnapshot().tasksBySession @@ -1133,7 +1133,7 @@ describe('background-task mirror', () => { }) it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true) manager.handleMuxEnvelope(tasksFrame(S1, [])) @@ -1141,7 +1141,7 @@ describe('background-task mirror', () => { }) it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleMuxEnvelope({ rpcId: 's' as never, @@ -1151,7 +1151,7 @@ describe('background-task mirror', () => { }) it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) @@ -1159,7 +1159,7 @@ describe('background-task mirror', () => { }) it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) const seen = vi.fn() manager.subscribe(seen) manager.handleMuxEnvelope(tasksFrame(S1, [view()])) diff --git a/packages/client/runtime/tests/partial.spec.ts b/packages/client/runtime/tests/partial.spec.ts index c1df190c33..328bd91ee4 100644 --- a/packages/client/runtime/tests/partial.spec.ts +++ b/packages/client/runtime/tests/partial.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' -import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client' +import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client' import { PartialAccumulator } from '../src/client/sessions/partial.ts' const chunk = (c: Record): StreamChunk => c as unknown as StreamChunk diff --git a/packages/client/runtime/tests/projection-store.spec.ts b/packages/client/runtime/tests/projection-store.spec.ts index 5451b9e8b7..45b75e91a1 100644 --- a/packages/client/runtime/tests/projection-store.spec.ts +++ b/packages/client/runtime/tests/projection-store.spec.ts @@ -8,11 +8,11 @@ * list rows' title projection). */ import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' // Test-domain keys merged into the projection map (the Service Definition package's @@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => { describe('Session tail-page seeding', () => { it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, @@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, @@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, 9) @@ -141,7 +141,7 @@ describe('manager frame routing', () => { it('lands session/projection frames before instantiation and the Session adopts the same store', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) manager.handleMuxEnvelope({ rpcId: 'p1' as never, payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never, @@ -158,7 +158,7 @@ describe('manager frame routing', () => { it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) @@ -181,7 +181,7 @@ describe('manager frame routing', () => { it('projects every retained value into list rows with stable snapshot identity', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, @@ -211,7 +211,7 @@ describe('manager frame routing', () => { it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api) + const manager = new SessionManager(api, fakeRemote()) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 1b6b721d25..d93b462d25 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -7,10 +7,10 @@ import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient } from './fake-api.ts' +import { FakeApiClient, fakeRemote } from './fake-api.ts' const SID = 'fk-q1' as SessionId const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] @@ -42,7 +42,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame { } function makeSession(): Session { - return new Session(SID, new FakeApiClient()) + return new Session(SID, new FakeApiClient(), fakeRemote()) } describe('queue snapshot intake', () => { @@ -198,7 +198,7 @@ describe('queue snapshot intake', () => { describe('queue operation transport', () => { it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { const api = new FakeApiClient() - const session = new Session(SID, api) + const session = new Session(SID, api, fakeRemote()) session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue @@ -251,14 +251,14 @@ describe('queue reconnect semantics', () => { describe('manager buffering of queue snapshots', () => { it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { - const manager = new SessionManager(new FakeApiClient()) + const manager = new SessionManager(new FakeApiClient(), fakeRemote()) manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) manager.handleMuxEnvelope({ rpcId: rid('g1b'), diff --git a/packages/client/runtime/tests/scope.spec.ts b/packages/client/runtime/tests/scope.spec.ts index 4c69b46edc..528c36131e 100644 --- a/packages/client/runtime/tests/scope.spec.ts +++ b/packages/client/runtime/tests/scope.spec.ts @@ -8,7 +8,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { createScope, scopeOf } from '../src/client/agents/scope.ts' const sid = (k: string): SessionId => k as SessionId diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 4304f67684..43a78cffb2 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -9,7 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-commands/types' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { Session } from '../src/client/sessions/session.ts' import type { ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, @@ -17,7 +17,7 @@ import type { ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, ConversationViewDefinition, } from '../src/client/index.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' const SID = 'fk-s1' as SessionId @@ -159,7 +159,7 @@ const TEST_CONVERSATION: ConversationRuntime = { } function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) } + return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) } } function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { @@ -343,7 +343,7 @@ describe('live event path', () => { entries: () => [testViewDefinition()], } as unknown as ConversationRuntime['views'], } - const session = new Session(SID, api, { conversation }) + const session = new Session(SID, api, fakeRemote(), { conversation }) await session.open() const snapshots: ConversationSnapshot[] = [] session.subscribe(() => { snapshots.push(session.getSnapshot()) }) @@ -448,7 +448,7 @@ describe('paging', () => { describe('prompt and cancel errors', () => { it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, { + const session = new Session(SID, api, fakeRemote(), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -487,7 +487,7 @@ describe('prompt and cancel errors', () => { api.onSubagentInterrupt = () => Promise.resolve(err({ code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, }) as never) - const session = new Session(SID, api, { + const session = new Session(SID, api, fakeRemote(), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -501,7 +501,7 @@ describe('prompt and cancel errors', () => { it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, { + const session = new Session(SID, api, fakeRemote(), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, }) await session.open() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 757376b616..956be93581 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -8,9 +8,9 @@ */ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -23,7 +23,7 @@ interface Bench { function bench(): Bench { const ctx = new Context() const api = new FakeApiClient() - const svc = new SessionsService(ctx, api) + const svc = new SessionsService(ctx, api, fakeRemote()) return { ctx, api, svc } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index a52eb4e3e9..73861b8003 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -6,7 +6,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' // Type-only: the api-remotes facade carries both the allowlist's selection seat // and the owner packages' `./types` declarations, which together give `$on` its diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index aa0f404da6..d0adfd3a7f 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -1,10 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId const wid = (id: string): WorkspaceId => id as WorkspaceId @@ -124,7 +124,7 @@ describe('WorkspacesService', () => { it('feeds readiness and recent-Workspace targeting without changing Host order', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [ @@ -152,7 +152,7 @@ describe('WorkspacesService', () => { it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[], @@ -211,7 +211,7 @@ describe('WorkspacesService', () => { it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] })) api.onList = () => Promise.resolve(ok({ @@ -231,7 +231,7 @@ describe('WorkspacesService', () => { it('returns created Workspaces and preserves Host business errors', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, @@ -250,7 +250,7 @@ describe('WorkspacesService', () => { it('passes native directory selection and cancellation through without local state', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') @@ -264,7 +264,7 @@ describe('WorkspacesService', () => { it('passes listings and creation through the browse wire, wrapping business failures', async () => { const ctx = new Context() const api = new FakeApiClient() - const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote())) const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } api.onListDirectory = () => Promise.resolve(ok(listing)) await expect(workspaces.listDirectory()).resolves.toEqual(listing) @@ -285,7 +285,7 @@ describe('WorkspacesService', () => { it('opens a filesystem path through the host without local state', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined() expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }]) @@ -296,7 +296,7 @@ describe('WorkspacesService', () => { it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) await workspaces.refresh() @@ -312,7 +312,7 @@ describe('WorkspacesService', () => { it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onList = () => Promise.resolve(ok({ items: [ @@ -358,7 +358,7 @@ describe('WorkspacesService', () => { it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }], @@ -392,7 +392,7 @@ describe('startInitialSelection', () => { function bench() { const ctx = new Context() const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + const sessions = new SessionsService(ctx, api, fakeRemote()) const workspaces = new WorkspacesService(ctx, api, sessions) return { api, sessions, workspaces } } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 6641467cc2..ce682b81f9 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../../host/apiproxy" }, @@ -60,7 +57,7 @@ "path": "../../typert/registry" }, { - "path": "../../api/gateway" + "path": "../../api/remotes/tsconfig.client.json" } ], "exclude": [ diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 6a58ff6603..30786bbb45 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -32,11 +32,11 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slash", - "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" } @@ -50,16 +50,16 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -72,6 +72,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-command/src/client/directory.ts b/packages/client/ui-command/src/client/directory.ts index 0e4fd916b2..3e6a9cf2eb 100644 --- a/packages/client/ui-command/src/client/directory.ts +++ b/packages/client/ui-command/src/client/directory.ts @@ -5,13 +5,10 @@ * / epoch-guard behavior of the original global cache; the session-key axis * is the only extra dimension. */ -import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -/** command.list success value, derived so the wire type authority stays in apiproxy. */ -type ListValue = Extract>['result'], { ok: true }>['value'] - -/** One host command descriptor as served to the client. */ -export type CommandDescriptor = ListValue['commands'][number] +export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' /** * cold = never pulled; pending = pull in flight with nothing servable; diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index 765e14f751..6f9dc56c94 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -44,8 +44,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'command' -/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */ -export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote'] +/** Required services: the '/' source registry, session scopes, commands Remote, and locale registry. */ +export const inject = ['slash', 'sessions', 'remote', 'remote.commands', 'locale'] /** * Client plugin body: mount the service, then register the popupSelect shell diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 86a7550cef..056067f46f 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -9,11 +9,10 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (`commands/change` rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, SubmitOutcome, @@ -96,7 +95,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandService extends Service implements CommandServiceContract { - static inject = ['slash', 'sessions', 'connection', 'remote'] + static inject = ['slash', 'sessions', 'remote', 'remote.commands'] private readonly directory: CommandDirectory private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() } @@ -107,13 +106,11 @@ export class CommandService extends Service implements CommandServiceContract { */ constructor(ctx: Context) { super(ctx, 'command') - const connection = ctx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error('ui-command: connection service unavailable') this.directory = new CommandDirectory(async (sessionId) => { if (this.sessions().subagentAddress(sessionId) !== undefined) return [] - const { result } = await connection.api.commands.list({ sessionId }) + const result = await ctx.remote.commands.list(sessionId) if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`) - return result.value.commands + return result.value }) const slash = ctx.get('slash') if (slash === undefined) throw new Error('ui-command: slash service unavailable') @@ -351,10 +348,9 @@ export class CommandService extends Service implements CommandServiceContract { session: ClientSessionContext, line: string, ): Promise { - const connection = this.ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line }) + const result = await this.ctx.remote.commands.execute(session.sessionId, line) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) - if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` } + if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } return { kind: 'success' } } diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index af60a45e16..b413e8d79e 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -14,7 +14,6 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandServiceContract } from '../src/client/contract.ts' import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, CommandService, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -33,14 +32,14 @@ async function bench() { scope: (id: SessionId) => scopes.get(id), scopeOf: (c: Context) => scopeOf(c), }) - ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } }) + const commandsRemote = { list: () => Promise.resolve([]) } + ctx.provide('remote', { commands: commandsRemote }) + ctx.provide('remote.commands', commandsRemote) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } }, } as never, (() => null) as never) ctx.provide('locale', new LocaleService(ctx)) - // CommandService injects `remote` for the forwarded directory invalidation. - new TestRemote(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const mint = (key: string) => { @@ -53,7 +52,7 @@ async function bench() { describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote']) + expect(inject).toEqual(['slash', 'sessions', 'remote', 'remote.commands', 'locale']) }) it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => { diff --git a/packages/client/ui-command/tests/directory.spec.ts b/packages/client/ui-command/tests/directory.spec.ts index c1c0b5a75d..3f0b1b91df 100644 --- a/packages/client/ui-command/tests/directory.spec.ts +++ b/packages/client/ui-command/tests/directory.spec.ts @@ -6,7 +6,7 @@ * gate, and the per-key ensureReady strong-wait policy. */ import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandDirectory } from '../src/client/directory.ts' diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index b2e5826cdd..861bcdc0aa 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -10,7 +10,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' @@ -32,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [ { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } }, ] -type ExecuteValue = { matched: boolean } +type ExecuteValue = { matched: boolean; commandId?: string } interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ @@ -46,20 +45,26 @@ async function bench(opts: BenchOptions = {}) { const registered = new Map() const listCalls: Array<{ sessionId: SessionId }> = [] const executeCalls: Array<{ sessionId: SessionId; line: string }> = [] - const api = { - commands: { - list: async (payload: { sessionId: SessionId }) => { - listCalls.push(payload) - const value = await (opts.commands ?? (p => Promise.resolve({ - commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS, - })))(payload) - return { result: { ok: true as const, value } } - }, - execute: async (payload: { sessionId: SessionId; line: string }) => { - executeCalls.push(payload) - const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload) - return { result: { ok: true as const, value } } - }, + // The service reads the generated commands Remote, which delivers the + // carrier's outcome, so a programmed failure answers the error branch. + const commandsRemote = { + list: async (sessionId: SessionId) => { + listCalls.push({ sessionId }) + const value = await (opts.commands ?? (p => Promise.resolve({ + commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS, + })))({ sessionId }) + return { ok: true as const, value: value.commands } + }, + execute: async (sessionId: SessionId, line: string) => { + executeCalls.push({ sessionId, line }) + const fallback = (): Promise => Promise.resolve({ matched: true }) + const value = await (opts.execute ?? fallback)({ sessionId, line }) + return { + ok: true as const, + value: value.matched + ? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } } + : undefined, + } }, } ctx.provide('slash', { @@ -78,10 +83,20 @@ async function bench(opts: BenchOptions = {}) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - ctx.provide('connection', { api }) - // CommandService injects `remote`; the directory invalidation arrives on the - // same `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const forwarded = new Map void>>() + ctx.provide('remote', { + commands: commandsRemote, + $on: (event: string, listener: (...args: never[]) => void) => { + const listeners = forwarded.get(event) ?? [] + listeners.push(listener) + forwarded.set(event, listeners) + return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) } + }, + $dispatch: (event: string, args: readonly unknown[]) => { + for (const listener of forwarded.get(event) ?? []) listener(...args as never[]) + }, + }) + ctx.provide('remote.commands', commandsRemote) /** Notices the fake conversation face collected (runDetached routing). */ const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = [] ctx.provide('conversation', { @@ -515,7 +530,13 @@ describe('detached admission notices', () => { mode = 'reject' menuPick(source, 'plan', proj('s1')) await flush() - expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }]) + // A dead Remote call and a rejected one now read alike: both arrive as a + // failed result, so the notice names the endpoint either way. + expect(notices).toEqual([{ + scope: sid('s1'), + level: 'error', + text: 'command.execute failed: internal: network down', + }]) }) it('a torn-down scope drops the failure notice', async () => { diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json index d87e874d01..255f8229b0 100644 --- a/packages/client/ui-command/tsconfig.json +++ b/packages/client/ui-command/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" @@ -32,6 +32,9 @@ { "path": "../ui-slots" }, + { + "path": "../../interaction/commands" + }, { "path": "../../support/invariants" }, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1f9642e236..1eabde8cb8 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -16,7 +16,7 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' -import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { SessionInputShell } from '../src/client/input/facade.ts' @@ -98,7 +98,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { api.onList = () => Promise.resolve(ok({ items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }], }) as never) - const sessions = new SessionsService(ctx, api) // provides 'sessions' itself + const sessions = new SessionsService(ctx, api, fakeRemote()) // provides 'sessions' itself await sessions.refresh() await Promise.resolve() // manager notifier flush await ctx.plugin(SlashService).await() diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 58af4f794f..0ef0bd8a19 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../connection" - }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 0a8f1512bf..4a60b9c272 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -45,7 +45,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -57,7 +57,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 746c34a4e8..4fe7bd3e37 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -7,7 +7,7 @@ * projection pair through the standard-kit `useProjection`; zero client-side * plan state. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -39,8 +39,8 @@ export interface PlanChipInjected { exitPlanMode: () => Promise } -/** Required services: the seat's slot registry, transport, and locale registry. */ -export const inject = ['slots', 'connection', 'locale'] +/** Required services: the seat's slot registry, commands Remote, and locale registry. */ +export const inject = ['slots', 'remote', 'remote.commands', 'locale'] /** * Client plugin body: register the plan chip over the command channel. @@ -55,10 +55,9 @@ export function apply(ctx: ClientContext): void { inject: (sessionId: SessionId): PlanChipInjected => ({ // Failure strings stay English (error-surface policy: not localized). exitPlanMode: async () => { - const connection = ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) + const result = await ctx.remote.commands.execute(sessionId, '/plan off') if (!result.ok) return `${result.error.message} (${result.error.code})` - if (!result.value.matched) return 'unknown command: /plan off' + if (result.value === undefined) return 'unknown command: /plan off' return null }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 79d2903e93..1a412c06de 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -25,16 +25,18 @@ async function bench() { name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, } as never, () => null) - const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) => - Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } })) - ctx.provide('connection', { api: { commands: { execute } } }) + const execute = vi.fn((_sessionId: SessionId, _line: string) => + Promise.resolve({ commandId: 'c1', result: { kind: 'success' as const } })) + const commandsRemote = { execute } + ctx.provide('remote', { commands: commandsRemote }) + ctx.provide('remote.commands', commandsRemote) ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots, execute } } describe('ui-plan browser apply', () => { it('declares every service it binds', () => { - expect(inject).toEqual(['slots', 'connection', 'locale']) + expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale']) }) it('node-half apply is an intentional no-op', () => { @@ -44,7 +46,8 @@ describe('ui-plan browser apply', () => { it('waits until conversation declares the plan seat', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - ctx.provide('connection', {}) + ctx.provide('remote', { commands: {} }) + ctx.provide('remote.commands', {}) ctx.provide('locale', new LocaleService(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -65,18 +68,17 @@ describe('ui-plan browser apply', () => { const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) await expect(injected.exitPlanMode()).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) + expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off') - // Business failure folds to the composer-visible line. - b.execute.mockResolvedValueOnce({ - result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, - } as never) + // Business failure folds to the composer-visible line: the generated method + // throws with the RPC failure as its cause. + b.execute.mockRejectedValueOnce(new Error('client api: commands/execute failed', { + cause: { code: 'session-not-found', message: 'gone', details: {} }, + })) await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. - b.execute.mockResolvedValueOnce({ - result: { ok: true as const, value: { matched: false as const } }, - } as never) + b.execute.mockResolvedValueOnce(undefined as never) await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off') await fiber.dispose() diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 0772c23b31..c9f65c0c36 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -8,15 +8,15 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, { "path": "../runtime" }, - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8bca4156a3..5a22398aa9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -67,7 +67,7 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' -// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. +// Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the @@ -2889,49 +2889,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - commands: { - // Both methods address one session's agent. agentFor resumes on miss - // and fences every subagent-owned identity with `agent-busy`; the - // api/commands.ts module contract owns that fence's wording, so this - // comment only notes the routing shape: clients send a sessionId for a - // published session, and resume restores an existing entity. - async list(request) { - // Missing service = the deployment omitted dsh-commands from its - // composition, not an empty catalog: fail loud instead of serving []. - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const found = await agentFor(request.payload.sessionId) - if ('error' in found) return err(request, found.error) - return ok(request, { commands: commands.list(found.agent) }) - }, - - async execute(request, signal) { - const commands = ctx.get('commands') - if (commands === undefined) { - return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const { sessionId, line } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - try { - // Pure admission: the executor's durable command/run + command/done - // pair (broadcast on the mux stream) carries the outcome; the - // response reports whether the line resolved to a handler, plus the - // minted pairing id so the issuing client can correlate its request - // with the flow node the lifecycle events produce. - const execution = await commands.execute(found.agent, line, signal) - return ok(request, execution === undefined - ? { matched: false } - : { matched: true, commandId: execution.commandId }) - } catch (error: unknown) { - if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} }) - return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} }) - } - }, - }, - goals: { // Mutations only — the read side is the 'goal' session projection. // Every verb resolves the session's agent (agentFor: implicit cold diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts deleted file mode 100644 index c135c82e5a..0000000000 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * commands domain zod schemas (names derived from map keys: commandListRequestSchema / - * commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema). - */ - -import { z } from 'zod' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' -import type { CommandDescriptor } from './commands.ts' - -/** CommandDescriptor row of command.list. */ -export const commandDescriptorSchema = z.object({ - name: z.string().min(1), - description: z.string(), - input: z.object({ hint: z.string() }).optional(), -}) satisfies z.ZodType> - -/** command.list request payload. */ -export const commandListRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** command.list response value. */ -export const commandListValueSchema = z.object({ - commands: z.array(commandDescriptorSchema), -}) satisfies z.ZodType>> - -/** command.execute request payload. */ -export const commandExecuteRequestSchema = z.object({ - sessionId: sessionIdSchema, - line: z.string(), -}) satisfies z.ZodType>> - -/** CommandId: one brand cast after schema validation (the only cast point in this domain). */ -export const commandIdSchema = z.string().min(1) as unknown as z.ZodType - -/** command.execute response value: pure admission — outcomes ride the logged - * lifecycle events; commandId (present exactly when matched) correlates with them. */ -export const commandExecuteValueSchema = z.object({ - matched: z.boolean(), - commandId: commandIdSchema.optional(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts deleted file mode 100644 index 984a184093..0000000000 --- a/packages/host/apiproxy/src/api/commands.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * commands domain contract: the web catalog/dispatch face of the host command - * registry (`ctx.commands`). Both methods address an ordinary session's Agent - * via `sessionId`, resuming it when cold. Session-backed subagents reject with - * `agent-busy` and retain their dedicated continuation owner. - */ - -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** - * Handler-free command view served to clients. Wire mirror of the host - * registry descriptor (which stays host-side with its cordis dependencies); - * no source field — the host descriptor has none. - */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: { readonly hint: string } -} - -/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */ -export interface CommandsApi { - /** - * Lists the addressed agent's effective command catalog (name-sorted, - * globals plus its scoped shadows). Session-backed subagents reject with - * `agent-busy`. - */ - list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Parses and executes one slash-command line against the addressed agent - * without sending it to the model — pure admission semantics. matched=false - * when syntax or name does not resolve (the client falls back to its - * default sink). The handler's outcome does NOT ride the response: the host - * executor durably logs the lifecycle (`command/run`/`command/done`), which - * broadcasts on the mux stream and renders as a persistent flow node. - * `commandId` is present exactly when matched — the minted lifecycle - * pairing id, letting the issuing client correlate this acknowledgment - * with that flow node. The signal rides beside the request, never on the - * wire: the fetch carrier's request signal cancels the running handler. - * Session-backed subagents reject with `agent-busy` before dispatch. - */ - execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): - Promise> -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5f72192969..7c7b43d650 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' @@ -25,7 +24,6 @@ export interface ApiProxy { subagents: SubagentsApi host: HostApi workspace: WorkspaceApi - commands: CommandsApi skills: SkillsApi agentPresets: AgentPresetsApi events: EventsApi @@ -52,7 +50,6 @@ export type { } from './subagents.ts' export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' -export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ca7231e774..f81ac842af 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -7,7 +7,6 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' -import type { CommandsApi } from './commands.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' @@ -50,8 +49,6 @@ export interface RpcMethodMap { 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] - 'command.list': CommandsApi['list'] - 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 3c74ad04ac..e4b6a2bed6 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,6 @@ import { workspaceListValueSchema, workspaceRenameValueSchema, } from '../api/workspace.schema.ts' -import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -120,10 +119,6 @@ export interface IApiClient { insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } - commands: { - list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> - execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise>> - } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } @@ -200,8 +195,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.archiveSession', payload, signal), } - readonly commands: IApiClient['commands'] = { - list: (payload, signal) => this.callUnary('command.list', payload, signal), - // Command handlers are user-driven operations and may legitimately exceed - // the transport health deadline. Caller/connection aborts remain. - execute: (payload, signal) => this.callUnary( - 'command.execute', payload, signal, 'caller-signal-only', - ), - } - readonly skills: IApiClient['skills'] = { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1e902f059e..1361bb9b1f 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,7 +42,6 @@ import { workspaceListRequestSchema, workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' -import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, @@ -115,8 +114,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, - 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, - 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ca0cf0329b..43b4c5d5b0 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -76,7 +76,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly subagents: ApiProxy['subagents'] readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] - readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] @@ -102,7 +101,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.subagents = api.subagents this.workspace = api.workspace this.host = api.host - this.commands = api.commands this.goals = api.goals this.skills = api.skills this.agentPresets = api.agentPresets diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts deleted file mode 100644 index 9abe116d31..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' -/** - * Command/skill RPC handlers and the two new frames over createApiProxy: - * command.list serves the addressed agent's effective catalog (missing - * registry = loud internal error), command.execute dispatches through the - * registry with the carrier signal, skill.list resolves cwd from the session - * header (never via the Agent registry), the host stream broadcasts - * commands-changed, and the mux stream carries live queued frames plus the - * open-time queue snapshot. - */ - -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import CommandService from '@deepseek-ai/dsh-commands' -import SkillService from '@deepseek-ai/dsh-skill' -import type { HostFrame } from '../src/api/index.ts' -import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' -import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts' - -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} -let nextRpc = 1 - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -function expectErr(response: RpcResponse): { code: string; message: string } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} - -/** Composition floor for the command/skill paths (no LLM, no persistence). */ -async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) - if (options.skills !== false) await ctx.plugin(SkillService, {}) - if (options.commands !== false) await ctx.plugin(CommandService) - // Host-stream opener reads the committed-workspace baseline; the stub - // suffices here — the real workspace composition is api-proxy-workspace.spec's. - ctx.provide('workspace', { list: () => [] } as never) - return ctx -} - -/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */ -function stubAgent(ctx: Context, sessionId?: SessionId): Agent { - const session = ctx.sessions.create(sessionId) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const agent = { - id: session.id, - session, - inbox, - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - return agent -} - -/** Drain `count` frames from a stream, then abort it. */ -async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { - const frames: F[] = [] - for await (const frame of iterable) { - frames.push(frame.payload) - if (frames.length >= count) abort.abort() - } - return frames -} - -/** Read the next payload from an open stream. */ -async function nextFrame(iterator: AsyncIterator>): Promise { - const result = await iterator.next() - if (result.done) throw new Error('stream ended') - return result.value.payload -} - -describe('command.list', () => { - it('serves the addressed agent\'s name-sorted catalog', async () => { - const ctx = await harness() - ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) }) - ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '' }, handler: () => ({ kind: 'success' }) }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.list(request({ sessionId: agent.id }))) - expect(value.commands).toEqual([ - { name: 'alpha', description: 'a', input: { hint: '' } }, - { name: 'zeta', description: 'z' }, - ]) - }) - - it('fails loud with internal when the command registry is not mounted', async () => { - const ctx = await harness({ commands: false }) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('command registry') - }) -}) - -describe('command.execute', () => { - it('executes a known command against the addressed agent and detaches the result', async () => { - const ctx = await harness() - let received: string | undefined - ctx.commands.register({ - name: 'goal', - description: 'set goal', - handler: (invocation) => { - received = invocation.rawInput - return { kind: 'success', text: `goal:${invocation.agent.id}` } - }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal)) - expect(value).toMatchObject({ matched: true }) - expect(value.commandId).toBeTruthy() - expect(received).toBe(' ship it') - // Pure admission on the wire: the outcome rides the durably logged - // lifecycle pair instead of the response. - const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done') - expect(lifecycle).toMatchObject([ - { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } }, - { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } }, - ]) - }) - - it('returns matched:false when syntax or name does not resolve', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const signal = new AbortController().signal - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false }) - expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false }) - }) - - it('maps a session miss to session-not-found and a registry gap to internal', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const missing = expectErr(await api.commands.execute( - request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal)) - expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate - - const bare = await harness({ commands: false }) - const bareApi = createApiProxy(bare, DEFAULTS) - expect(expectErr(await bareApi.commands.execute( - request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal') - }) - - it('reports an aborted handler as cancelled and a throwing handler as internal', async () => { - const ctx = await harness() - ctx.commands.register({ - name: 'hang', - description: 'never settles on its own', - handler: () => new Promise(() => { /* settled only by abort */ }), - }) - ctx.commands.register({ - name: 'boom', - description: 'throws', - handler: () => { throw new Error('kaboom') }, - }) - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - - const controller = new AbortController() - const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal) - controller.abort() - expect(expectErr(await pending).code).toBe('cancelled') - - const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal)) - expect(thrown.code).toBe('internal') - expect(thrown.message).toContain('kaboom') - }) -}) - -describe('skill.list', () => { - it('lists skills for the session cwd taken from the header', async () => { - const ctx = await harness() - const seenCwds: (string | undefined)[] = [] - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: (options) => { - seenCwds.push(options.cwd) - return Promise.resolve([ - { - name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - { - name: 'trusted-only', description: 'Trusted-only', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ]) - }, - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - // No agent is registered for this session: header resolution must not - // touch (or resume through) the Agent registry. - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, - { name: 'user-only', description: 'User-only', modelInvocable: false }, - ]) - expect(seenCwds).toEqual(['/proj']) - expect(ctx.agents.get(session.id)).toBeUndefined() - }) - - it('fails loud on an unattached session id (business error, no resume attempt)', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId }))) - expect(error.code).toBe('session-not-found') - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const error = expectErr(await api.skills.list(request({ sessionId: session.id }))) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) - - it('folds a provider failure into internal', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'broken', - list: () => Promise.reject(new Error('directory exploded')), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const response = await api.skills.list(request({ sessionId: session.id })) - // dsh-skill contains one provider's failure (logs and serves the rest), so - // this surfaces as an empty ok catalog rather than an error. - const value = expectOk(response) - expect(value.skills).toEqual([]) - }) -}) - -describe('forwarded commands/change frame', () => { - it('broadcasts on registry change', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const abort = new AbortController() - const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal) - const collected = collect(stream, 1, abort) - ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) }) - // Verbatim forwarding: the wire name is the host's own event name and - // `args` is its argument list (empty for this pure invalidation). - expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }]) - }) - - // The guard belongs to the forwarding boundary, so it is tested there rather - // than through a malformed `ctx.emit`: every currently allowlisted event has a - // statically JSON-safe payload, so no type-legal emit can reach the rejection - // branch. These cases stand in for a future allowlist entry whose payload the - // wire cannot carry — a composition mistake that must fail loud. - describe('assertJsonArgs', () => { - it('passes a JSON-safe argument list through unchanged', () => { - const args = ['llm-deepseek', 7, null, { nested: ['ok'] }] - expect(assertJsonArgs('settings/document-updated', args)).toEqual(args) - expect(assertJsonArgs('commands/change', [])).toEqual([]) - }) - - it('names the offending event and argument position when a payload is not lossless JSON', () => { - expect(() => assertJsonArgs('credentials/updated', [1n])) - .toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data') - expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}])) - .toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data') - }) - }) -}) - -/** Build one frozen inbox message. */ -function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { - return freezeMessage({ - id: MessageId(id), - role: 'user', - content: [{ type: 'text' as const, text }], - source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) }, - }) -} - -describe('session.updateQueue', () => { - it('splices a queued message and reports a lost claim race', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const present = inboxMessage('present', 'before') - agent.inbox.splice('next-turn', 0, 0, [present]) - const api = createApiProxy(ctx, DEFAULTS) - - const applied = await api.sessions.updateQueue({ - rpcId: RpcId('q-apply'), - payload: { - sessionId: agent.id, - itemId: MessageId('present'), - action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, - }, - }) - expect(expectOk(applied)).toEqual({ accepted: true }) - const missing = await api.sessions.updateQueue({ - rpcId: RpcId('q-missing'), - payload: { - sessionId: agent.id, - itemId: MessageId('claimed'), - action: { kind: 'remove' }, - }, - }) - expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) - expect(agent.inbox.nextTurn[0]).toMatchObject({ - id: 'present', - content: [{ type: 'text', text: 'edited' }], - }) - }) - - it('rejects a stale occurrence without resuming a cold agent', async () => { - const ctx = await harness() - const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, DEFAULTS) - const response = await api.sessions.updateQueue({ - rpcId: RpcId('q-cold'), - payload: { - sessionId: 'cold-session' as SessionId, - itemId: MessageId('stale-item'), - action: { kind: 'remove' }, - }, - }) - - expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' }) - expect(resume).not.toHaveBeenCalled() - }) -}) - -describe('session/queue frames', () => { - it('publishes authoritative inbox snapshots without duplicating message identity', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const queued = inboxMessage('m-1', 'queued prompt') - const edited = inboxMessage('m-1', 'edited prompt') - const steering = inboxMessage('m-2', 'steering prompt') - agent.inbox.splice('next-turn', 0, 0, [queued]) - agent.inbox.splice('next-step', 0, 0, [steering]) - - const abort = new AbortController() - const iterator = api.events.mux({ - rpcId: RpcId('t-mux-baseline'), - payload: {}, - }, abort.signal)[Symbol.asyncIterator]() - const frames = [ - await nextFrame(iterator), - await nextFrame(iterator), - ] - agent.inbox.splice('next-turn', 0, 1, [edited]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - const injected = freezeMessage({ - id: MessageId('m-3'), - role: 'user', - content: [{ type: 'text' as const, text: 'injected context' }], - source: { kind: 'plugin' as const, plugin: 'approval' }, - }) - agent.inbox.splice('next-step', 0, 0, [injected]) - frames.push(await nextFrame(iterator), await nextFrame(iterator)) - abort.abort() - await iterator.return?.() - - expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([ - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: queued.id, placement: 'queued', message: queued }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - { - type: 'session/queue', - sessionId: agent.id, - items: [ - { id: edited.id, placement: 'queued', message: edited }, - { id: injected.id, placement: 'context', message: injected }, - { id: steering.id, placement: 'steering', message: steering }, - ], - }, - ]) - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index fdee096348..15888b3cfb 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -21,7 +21,6 @@ function scriptedApi(overrides: { sessions?: Partial subagents?: Partial host?: Partial - commands?: Partial skills?: Partial agentPresets?: Partial events?: Partial @@ -89,11 +88,6 @@ function scriptedApi(overrides: { insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, - commands: { - list: r => ok(r, { commands: [] }), - execute: r => ok(r, { matched: false }), - ...overrides.commands, - }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 1888cf602a..5ee9b54060 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,4 +1,3 @@ -import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' @@ -190,25 +189,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } }, }, - commands: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } } } - }, - async execute(request, signal) { - if (request.payload.line === '/hang') { - // Cooperative hang: settles only through the carrier signal (sticky - // abort checked first — listeners never fire retroactively). - if (!signal.aborted) { - await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - } - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } } - } - if (request.payload.line.startsWith('/plan')) { - return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: CommandId('cmd-x') } } } - } - return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } - }, - }, agentPresets: { list(request: RpcRequest<{}>) { return Promise.resolve({ @@ -441,19 +421,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { opened: true } }) }) - it('round-trips command.list / command.execute / skill.list through the wire form', async () => { + it('round-trips skill.list through the wire form', async () => { const c = client() - const list = await c.commands.list({ sessionId: 's' as never }) - expect(list.result).toEqual({ ok: true, value: { commands: [{ name: 'plan', description: 'Toggle plan mode', input: { hint: 'on|off' } }] } }) - const hit = await c.commands.execute({ sessionId: 's' as never, line: '/plan off' }) - expect(hit.result).toEqual({ ok: true, value: { matched: true, commandId: 'cmd-x' } }) - const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) - expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets command.execute finish after the 30-second default unary deadline', async () => { + it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { vi.useFakeTimers() const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { const controller = new AbortController() @@ -464,16 +438,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) try { const api = fakeApi() - api.commands.execute = async (request) => { + api.host.pickDirectory = async (request) => { await new Promise(resolve => setTimeout(resolve, 30_001)) - return { - rpcId: request.rpcId, - result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } }, - } + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } } - const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' }) + const execution = client(api).host.pickDirectory({}) const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } }, + result: { ok: true, value: { path: '/tmp/slow' } }, }) await Promise.all([ @@ -509,10 +480,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { })).result).toEqual({ ok: true, value: { accepted: true } }) }) - it('keeps caller and connection aborts on command.execute', async () => { + it('keeps caller and connection aborts on a deadline-exempt unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.commands.execute = async (request, signal) => { + api.host.pickDirectory = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -525,10 +496,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).commands.execute( - { sessionId: 's' as never, line: '/hang' }, - controller.signal, - ) + const execution = client(api).host.pickDirectory({}, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 3865fbd2ae..1b377bebce 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -30,6 +30,14 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -37,7 +45,12 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -46,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -54,6 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 56b5d4da14..fb281cc543 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -3,26 +3,27 @@ * @module @deepseek-ai/dsh-commands */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' import { CommandId } from './brand.ts' +import type { + CommandDescriptor, + CommandExecution, + CommandInputDescriptor, + CommandResult, +} from './types.ts' export { CommandId } from './brand.ts' -export type { CommandSource, CommandSourceMap } from './types.ts' +export type * from './types.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Immutable metadata for a command's optional unstructured input. */ -export interface CommandInputDescriptor { - /** Placeholder shown before the user supplies free-form input. */ - readonly hint: string -} - /** Invocation passed to one registered command handler. */ export interface CommandInvocation { /** Pairing id already written to this invocation's `command/run` event. */ @@ -35,29 +36,6 @@ export interface CommandInvocation { readonly signal: AbortSignal } -/** Expected command outcome rendered directly by the dispatching UI. */ -export type CommandResult = - | { - readonly kind: 'success' - readonly text?: string - /** Earlier authoritative domain event that owns a richer presentation. */ - readonly sourceEventSeq?: number - } - | { readonly kind: 'error'; readonly text: string } - -/** - * One settled command execution: the handler's normalized result plus the - * lifecycle pairing id minted for its `command/run`/`command/done` records, - * so a dispatching surface can correlate the RPC-level acknowledgment with - * the flow node those events produce. - */ -export interface CommandExecution { - /** Pairing id carried by this execution's lifecycle events. */ - readonly commandId: CommandId - /** The handler's normalized outcome. */ - readonly result: CommandResult -} - /** Plugin-owned command registration. */ export interface CommandDefinition { /** Lowercase command name without the leading slash. */ @@ -76,16 +54,6 @@ export interface CommandDefinition { readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } -/** Handler-free immutable command view returned to UI adapters. */ -export interface CommandDescriptor { - /** Lowercase command name without the leading slash. */ - readonly name: string - /** Human-readable summary used in discovery UI. */ - readonly description: string - /** Optional free-form input hint advertised to capable clients. */ - readonly input?: CommandInputDescriptor -} - /** Syntactically valid slash command before registry resolution. */ export interface ParsedCommand { /** Lowercase command name without the leading slash. */ @@ -254,7 +222,7 @@ function normalizeResult(command: string, value: unknown): CommandResult { * registered through a command-injected child of an agent context shadow * globals for that agent. */ -export class CommandService extends Service { +export class CommandService extends GatewayService { private readonly layers = new ScopedLayers( scope => new CommandLayer(scope), () => { this.notifyChange() }, @@ -288,6 +256,7 @@ export class CommandService extends Service { * @param agent - exact receiving agent and scoped-layer key. * @returns name-sorted descriptors after scoped shadowing. */ + @Remote list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] .map(command => command.descriptor) @@ -324,6 +293,7 @@ export class CommandService extends Service { * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ + @Remote async execute( agent: Agent, line: string, diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 27309355d4..32f1dbcc43 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -9,6 +9,45 @@ import type { CommandId } from './brand.ts' +/** Immutable metadata for a command's optional unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } + | { readonly kind: 'error'; readonly text: string } + +/** + * One settled command execution: the handler's normalized result plus the + * lifecycle pairing id minted for its `command/run`/`command/done` records, + * so a dispatching surface can correlate the Remote acknowledgment with the + * flow node those events produce. + */ +export interface CommandExecution { + /** Pairing id carried by this execution's lifecycle events. */ + readonly commandId: CommandId + /** The handler's normalized outcome. */ + readonly result: CommandResult +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor +} + /** * Producer record for one command invocation (the `command/run` event's * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 901c76a377..128d7d9147 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d63ee5fa71..62e3dc5898 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1566,6 +1566,12 @@ importers: '@deepseek-ai/dsh-client-ui-deliverables': specifier: workspace:^ version: link:../../client/ui-deliverables + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal @@ -1862,9 +1868,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../../api/gateway + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2049,6 +2055,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2190,6 +2199,82 @@ importers: specifier: ~18.3.1 version: 18.3.31 + packages/client/ui-directory-picker: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-directory-picker-native: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-goal: devDependencies: '@deepseek-ai/cordis': @@ -2417,9 +2502,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2606,6 +2691,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2630,9 +2718,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../../api/gateway '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2988,6 +3073,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -4777,6 +4865,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-client-ui-directory-picker': + specifier: workspace:^ + version: link:../../client/ui-directory-picker + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../client/ui-directory-picker-native '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker @@ -4801,40 +4895,13 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../client/test-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../../client/ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/host/directory-picker-native: dependencies: @@ -4851,24 +4918,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 tsx: specifier: ^4.19.2 version: 4.22.4 @@ -4925,6 +4977,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta packages/interaction/permission: dependencies: From 40af20cafea9eb8515b7598385caa1d13c97d8e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:10:50 +0800 Subject: [PATCH 21/46] refactor(picker): split the directory-picker faces into their own packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browse and native backends were dual-face packages: a Node backend plus a browser surface under one tsconfig that referenced Client packages. That put Client projects — and through them the Client runtime — inside the Host compiler aggregate, which builds before the generated Remote contributions exist. Each browser half moves to its own Client package, and both backends become Node-only. The interaction is still one choice: the adaptive chooser mounts the backend and its surface as a pair of Loader entries and tears both down in reverse, so a resolved kind still swaps both faces. Compositions that pin an interaction directly now pin the pair, and the chooser's runtime-string package list keeps naming everything a composing app must resolve. --- apps/cli/tests/web-agent-presets.e2e.ts | 5 +- apps/web/tests/pin-browse-picker.overlay.yml | 2 + apps/web/tests/scaffold.ts | 5 +- packages/bundle/web-app/package.json | 2 + .../ui-directory-picker-native/package.json | 72 +++++++++++++++++ .../src/client/flow.ts | 0 .../src/client/index.ts | 0 .../ui-directory-picker-native/src/index.ts | 10 +++ .../src/invariant.ts | 31 +++++++ .../tests/client-flow.spec.tsx | 0 .../ui-directory-picker-native/tsconfig.json | 24 ++++++ .../tsdown.config.ts | 3 + .../client/ui-directory-picker/package.json | 80 +++++++++++++++++++ .../src/client/DirectoryBrowser.module.css | 0 .../src/client/DirectoryBrowser.tsx | 0 .../ui-directory-picker}/src/client/flow.ts | 0 .../ui-directory-picker}/src/client/index.ts | 0 .../ui-directory-picker}/src/css-modules.d.ts | 0 .../client/ui-directory-picker/src/index.ts | 10 +++ .../ui-directory-picker/src/invariant.ts | 31 +++++++ .../tests/client-flow.spec.tsx | 0 .../tests/directory-browser.spec.tsx | 0 .../client/ui-directory-picker/tsconfig.json | 30 +++++++ .../ui-directory-picker/tsdown.config.ts | 3 + .../host/directory-picker-auto/package.json | 12 ++- .../host/directory-picker-auto/src/index.ts | 61 +++++++++----- .../host/directory-picker-browse/package.json | 34 +------- .../directory-picker-browse/tsconfig.json | 17 +--- .../directory-picker-browse/tsdown.config.ts | 16 +++- .../host/directory-picker-native/package.json | 25 +----- .../directory-picker-native/tsconfig.json | 11 +-- .../directory-picker-native/tsdown.config.ts | 48 ++++++----- scripts/verify-cordis-config.ts | 11 ++- 33 files changed, 407 insertions(+), 136 deletions(-) create mode 100644 packages/client/ui-directory-picker-native/package.json rename packages/{host/directory-picker-native => client/ui-directory-picker-native}/src/client/flow.ts (100%) rename packages/{host/directory-picker-native => client/ui-directory-picker-native}/src/client/index.ts (100%) create mode 100644 packages/client/ui-directory-picker-native/src/index.ts create mode 100644 packages/client/ui-directory-picker-native/src/invariant.ts rename packages/{host/directory-picker-native => client/ui-directory-picker-native}/tests/client-flow.spec.tsx (100%) create mode 100644 packages/client/ui-directory-picker-native/tsconfig.json create mode 100644 packages/client/ui-directory-picker-native/tsdown.config.ts create mode 100644 packages/client/ui-directory-picker/package.json rename packages/{host/directory-picker-browse => client/ui-directory-picker}/src/client/DirectoryBrowser.module.css (100%) rename packages/{host/directory-picker-browse => client/ui-directory-picker}/src/client/DirectoryBrowser.tsx (100%) rename packages/{host/directory-picker-browse => client/ui-directory-picker}/src/client/flow.ts (100%) rename packages/{host/directory-picker-browse => client/ui-directory-picker}/src/client/index.ts (100%) rename packages/{host/directory-picker-browse => client/ui-directory-picker}/src/css-modules.d.ts (100%) create mode 100644 packages/client/ui-directory-picker/src/index.ts create mode 100644 packages/client/ui-directory-picker/src/invariant.ts rename packages/{host/directory-picker-browse => client/ui-directory-picker}/tests/client-flow.spec.tsx (100%) rename packages/{host/directory-picker-browse => client/ui-directory-picker}/tests/directory-browser.spec.tsx (100%) create mode 100644 packages/client/ui-directory-picker/tsconfig.json create mode 100644 packages/client/ui-directory-picker/tsdown.config.ts diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 8620a8f424..f34de5ad07 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -86,7 +86,10 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis // host and so waits for the webserver disabled above; the browse variant // supplies `directoryPicker` without one. { id: 'directory-picker', disabled: true }, - { insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] }, + { insert: [ + { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }, + { id: 'ui-directory-picker', name: '@deepseek-ai/dsh-client-ui-directory-picker' }, + ] }, // The roster AppCLIEntry would patch in; only the shipped root, so a // developer's own `~/.dsh/.preset` cannot change this test's outcome. // `default` here is the COMPOSITION default — the base layer the settings diff --git a/apps/web/tests/pin-browse-picker.overlay.yml b/apps/web/tests/pin-browse-picker.overlay.yml index c6bf121b69..e674f81d4c 100644 --- a/apps/web/tests/pin-browse-picker.overlay.yml +++ b/apps/web/tests/pin-browse-picker.overlay.yml @@ -9,3 +9,5 @@ - insert: - id: directory-picker-browse name: '@deepseek-ai/dsh-host-directory-picker-browse' + - id: ui-directory-picker + name: '@deepseek-ai/dsh-client-ui-directory-picker' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 87f75b383d..d3f373614e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -439,7 +439,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx similarity index 100% rename from packages/host/directory-picker-native/tests/client-flow.spec.tsx rename to packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx diff --git a/packages/client/ui-directory-picker-native/tsconfig.json b/packages/client/ui-directory-picker-native/tsconfig.json new file mode 100644 index 0000000000..25f63f1d78 --- /dev/null +++ b/packages/client/ui-directory-picker-native/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../ui-slots" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-workspace" + } + ] +} diff --git a/packages/client/ui-directory-picker-native/tsdown.config.ts b/packages/client/ui-directory-picker-native/tsdown.config.ts new file mode 100644 index 0000000000..94d4ed5ea8 --- /dev/null +++ b/packages/client/ui-directory-picker-native/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-directory-picker/package.json b/packages/client/ui-directory-picker/package.json new file mode 100644 index 0000000000..fd3acf592d --- /dev/null +++ b/packages/client/ui-directory-picker/package.json @@ -0,0 +1,80 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-directory-picker", + "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-directory-picker" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker/src/client/DirectoryBrowser.module.css similarity index 100% rename from packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css rename to packages/client/ui-directory-picker/src/client/DirectoryBrowser.module.css diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker/src/client/DirectoryBrowser.tsx similarity index 100% rename from packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx rename to packages/client/ui-directory-picker/src/client/DirectoryBrowser.tsx diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker/src/client/flow.ts similarity index 100% rename from packages/host/directory-picker-browse/src/client/flow.ts rename to packages/client/ui-directory-picker/src/client/flow.ts diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker/src/client/index.ts similarity index 100% rename from packages/host/directory-picker-browse/src/client/index.ts rename to packages/client/ui-directory-picker/src/client/index.ts diff --git a/packages/host/directory-picker-browse/src/css-modules.d.ts b/packages/client/ui-directory-picker/src/css-modules.d.ts similarity index 100% rename from packages/host/directory-picker-browse/src/css-modules.d.ts rename to packages/client/ui-directory-picker/src/css-modules.d.ts diff --git a/packages/client/ui-directory-picker/src/index.ts b/packages/client/ui-directory-picker/src/index.ts new file mode 100644 index 0000000000..6b812b5e75 --- /dev/null +++ b/packages/client/ui-directory-picker/src/index.ts @@ -0,0 +1,10 @@ +/** + * Directory-picker browsing surface, node half. Pure UI plugin: the empty + * apply exists so the plugin appears in the host cordis.yml / Loader; the + * browser half ships via exports["./client"], discovered through the + * package.json dsh.client declaration. The listing and creation primitives it + * drives live in `@deepseek-ai/dsh-host-directory-picker-browse`. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-directory-picker/src/invariant.ts b/packages/client/ui-directory-picker/src/invariant.ts new file mode 100644 index 0000000000..722f8177d5 --- /dev/null +++ b/packages/client/ui-directory-picker/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-directory-picker`. + * @module @deepseek-ai/dsh-client-ui-directory-picker/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-directory-picker' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-directory-picker-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the plugin registers one workspace directory-flow + * owner whose disposal the HMR-safety spec proves, and every listing it shows + * is re-read from the Host on demand rather than held here. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/client/ui-directory-picker/tests/client-flow.spec.tsx similarity index 100% rename from packages/host/directory-picker-browse/tests/client-flow.spec.tsx rename to packages/client/ui-directory-picker/tests/client-flow.spec.tsx diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/client/ui-directory-picker/tests/directory-browser.spec.tsx similarity index 100% rename from packages/host/directory-picker-browse/tests/directory-browser.spec.tsx rename to packages/client/ui-directory-picker/tests/directory-browser.spec.tsx diff --git a/packages/client/ui-directory-picker/tsconfig.json b/packages/client/ui-directory-picker/tsconfig.json new file mode 100644 index 0000000000..bd4e3dfc7f --- /dev/null +++ b/packages/client/ui-directory-picker/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-workspace" + } + ] +} diff --git a/packages/client/ui-directory-picker/tsdown.config.ts b/packages/client/ui-directory-picker/tsdown.config.ts new file mode 100644 index 0000000000..4900e78a04 --- /dev/null +++ b/packages/client/ui-directory-picker/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 572c1045c8..0c791b79a2 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -32,21 +32,25 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 91343463fd..0d836419a0 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -1,12 +1,13 @@ /** * Adaptive chooser of the directory-picker seam: resolves the host's * situation once at boot (bind host, SSH launch, display session, Linux - * chooser binary) and mounts the matching dual-face backend — `-native` or - * `-browse` — as a real Loader entry in the in-memory root tree. Because the - * backend arrives as an ordinary entry, its browser half is discovered - * exactly as a config-row's would be, so the seam's one-row-swaps-both-faces - * invariant holds for the resolved choice; pinning an interaction remains - * composing that backend row directly instead of this one. + * chooser binary) and mounts the matching interaction — `native` or `browse` + * — as real Loader entries in the in-memory root tree. Each interaction is a + * pair: the Host backend serving the seam capability and the client surface + * occupying ui-workspace's directory-flow holes. Both arrive as ordinary + * entries, so the surface is discovered exactly as a config-row's would be + * and one resolved choice still swaps both faces; pinning an interaction + * remains composing that pair directly instead of this row. * @module @deepseek-ai/dsh-host-directory-picker-auto */ @@ -28,7 +29,7 @@ export const name = 'directory-picker-auto' export const inject = ['httpServer', 'loader'] /** - * Backend package per resolved kind — fixed composition vocabulary, not a + * Host backend package per resolved kind — fixed composition vocabulary, not a * tunable. Exported because the reference is a runtime string the static * config gate cannot see in a yml row: `verify-cordis-config` requires every * app composing this chooser to declare both values as dependencies. @@ -39,10 +40,20 @@ export const BACKEND_PACKAGES: Record = { } /** - * Resolve the backend from one boot-time sample and mount it as a Loader - * entry; the effect's disposer removes the entry and joins the backend - * fiber's teardown, so unloading this plugin returns only after both faces - * of the mounted backend (and their dependents) quiesced. + * Client surface package per resolved kind, mounted with its backend so one + * resolved interaction still composes both faces. Declared as dependencies by + * every composing app for the same reason as {@link BACKEND_PACKAGES}. + */ +export const SURFACE_PACKAGES: Record = { + native: '@deepseek-ai/dsh-client-ui-directory-picker-native', + browse: '@deepseek-ai/dsh-client-ui-directory-picker', +} + +/** + * Resolve the interaction from one boot-time sample and mount its backend and + * surface as Loader entries; the effect's disposer removes both entries and + * joins their fibers' teardown, so unloading this plugin returns only after + * both faces of the mounted interaction (and their dependents) quiesced. * @param ctx - cordis context carrying the injected `httpServer` and `loader`. */ export async function apply(ctx: Context): Promise { @@ -54,16 +65,22 @@ export async function apply(ctx: Context): Promise { }) await ctx.effect(async () => { // Root-tree create: the Loader root is in-memory (write() is a no-op), so - // the mounted row can never be persisted back into a config file. - const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] }) - return async () => { - // Tree teardown (group.stop) can have removed the entry already; - // nothing is left to unmount or await then. - const entry = ctx.loader.store[id] - if (entry === undefined) return - // remove() disposes the entry transactionally, so the chooser's unload - // signals completion only after the backend quiesced. - await ctx.loader.remove(id) + // the mounted rows can never be persisted back into a config file. The + // backend lands first: the surface's browser half drives the capability + // the backend registers. + const ids: string[] = [] + for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { + ids.push(await ctx.loader.create({ name })) } - }, 'directory-picker-auto: backend entry') + return async () => { + for (const id of ids.reverse()) { + // Tree teardown (group.stop) can have removed the entry already; + // nothing is left to unmount or await then. + if (ctx.loader.store[id] === undefined) continue + // remove() disposes the entry transactionally, so the chooser's unload + // signals completion only after that face quiesced. + await ctx.loader.remove(id) + } + } + }, 'directory-picker-auto: interaction entries') } diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index f134a00dbf..dbad0318a6 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -22,55 +22,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/client.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" - } + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json index 00dcdf8fde..b6a0f96d7e 100644 --- a/packages/host/directory-picker-browse/tsconfig.json +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.client.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/types", @@ -16,21 +16,6 @@ }, { "path": "../../support/invariants" - }, - { - "path": "../../client/ui-slots" - }, - { - "path": "../../client/ui-primitives" - }, - { - "path": "../../client/locale" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-browse/tsdown.config.ts b/packages/host/directory-picker-browse/tsdown.config.ts index 4b2be38c3d..388cecbcb5 100644 --- a/packages/host/directory-picker-browse/tsdown.config.ts +++ b/packages/host/directory-picker-browse/tsdown.config.ts @@ -1,3 +1,15 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { defineConfig } from 'tsdown' -export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js']) +/** Node-only backend: listing and creation primitives over the host filesystem. */ +export default defineConfig([ + { + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 4465553e07..fbc7ae16d5 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -22,10 +22,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, "./worker": { "types": "./lib/types/win32-dialog-worker.d.ts", "default": "./lib/worker.cjs" @@ -37,7 +33,6 @@ "lib/index.js", "lib/invariant.js", "lib/worker.cjs", - "lib/client.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -47,30 +42,12 @@ "koffi": "^3.1.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", "tsx": "^4.19.2" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace" - ], - "platform": "web" - } } } diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json index 395595e836..6962312bd1 100644 --- a/packages/host/directory-picker-native/tsconfig.json +++ b/packages/host/directory-picker-native/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.client.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/types", @@ -19,15 +19,6 @@ }, { "path": "../../util/native-command" - }, - { - "path": "../../client/ui-slots" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 6d02727f4e..13a7f74070 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -1,23 +1,31 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { defineConfig } from 'tsdown' -// The Win32 dialog worker builds as its own CJS entry (mirroring -// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining -// the dialog logic while koffi stays an external native require. -export default clientBundle( - '@deepseek-ai/dsh-host-directory-picker-native', - ['lib/types/index.js', 'lib/types/invariant.js'], +/** + * Node-only backend. The Win32 dialog worker builds as its own CJS entry + * (mirroring dsh-workflow-workerthread's worker): path-loaded by the driver, + * inlining the dialog logic while koffi stays an external native require. + */ +export default defineConfig([ { - companions: [{ - // The artifact is lib/worker.cjs (the ./worker export the workspace - // constraint keys on), bundled from the descriptive source entry. - entry: { worker: 'lib/types/win32-dialog-worker.js' }, - outDir: 'lib', - format: ['cjs'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, }, -) + { + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index d834094d78..686f1ace61 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -42,14 +42,17 @@ const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept' const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' /** - * The backends the chooser mounts by runtime string (mirror of its exported - * `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting - * the chooser must resolve both, or keyless Linux CI (which only ever - * resolves `browse`) hides a dropped `-native` dependency until a macOS boot. + * The packages the chooser mounts by runtime string (mirror of its exported + * `BACKEND_PACKAGES` and `SURFACE_PACKAGES`), invisible to yml-row scanning: a + * composition mounting the chooser must resolve every one, or keyless Linux CI + * (which only ever resolves `browse`) hides a dropped `-native` dependency + * until a macOS boot. */ const CHOOSER_BACKEND_PACKAGES = [ '@deepseek-ai/dsh-host-directory-picker-native', '@deepseek-ai/dsh-host-directory-picker-browse', + '@deepseek-ai/dsh-client-ui-directory-picker', + '@deepseek-ai/dsh-client-ui-directory-picker-native', ] const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', From 027cbdfe5d50cf91b2c7a6ddd2763c1a760b007d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:11:08 +0800 Subject: [PATCH 22/46] refactor(faces): keep the generated contributions out of the Host aggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gateway and the carrier each compiled both halves under one tsconfig, so the Host aggregate built their browser faces — including the face that owns `ctx.remote`, the most likely future consumer of a generated `/remote` contribution. Both packages now expose a host and a client face, and each aggregate references only its own; three modules the halves share appear in both file lists, as api/remotes already does. The two apps/web specs in the Host aggregate restate the conversation engine's Context key format instead of importing the Client runtime for it. A drift makes the key miss its rendered node, so the assertion fails loudly. The Host aggregate now reaches one Client project, the carrier's host face, which the Gateway's own dispatch face needs; no generated contribution is reachable from it. --- .../tests/chat-continuous-conversation.e2e.ts | 3 +- apps/web/tests/chat-long-interactions.e2e.ts | 3 +- apps/web/tests/support.ts | 14 ++++++ packages/api/gateway/tsconfig.client.json | 22 +++++++++ packages/api/gateway/tsconfig.host.json | 30 ++++++++++++ packages/api/gateway/tsconfig.json | 22 ++------- packages/api/remotes/tsconfig.client.json | 5 +- .../client/connection/tsconfig.client.json | 49 +++++++++++++++++++ packages/client/connection/tsconfig.host.json | 33 +++++++++++++ packages/client/connection/tsconfig.json | 41 ++-------------- tsconfig.client.json | 19 +++---- tsconfig.host.json | 10 +--- 12 files changed, 170 insertions(+), 81 deletions(-) create mode 100644 packages/api/gateway/tsconfig.client.json create mode 100644 packages/api/gateway/tsconfig.host.json create mode 100644 packages/client/connection/tsconfig.client.json create mode 100644 packages/client/connection/tsconfig.host.json diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index a7b659dab2..cd15f2e054 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -12,14 +12,13 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const TURN_COUNT = 12 diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index aca85146b5..58d97e5e3e 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -11,7 +11,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, @@ -20,7 +19,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const SESSION_ID = 'chat-long-interactions-e2e' diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index ee2a1a1a62..38d0849784 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -119,3 +119,17 @@ export async function saveFailureShot(page: Page, name: string): Promise { // Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error. } } + +/** + * The conversation engine's Context key format, restated here rather than + * imported: these specs live in the Host compiler aggregate, which must not + * reach the Client plane. The engine's own copy is + * `conversationContextKey` in dsh-client-runtime; a drift between them makes + * the key miss its rendered node, so the assertion fails loudly. + * @param kind - Definition kind. + * @param id - Definition-local business identity. + * @returns the engine-owned Context key. + */ +export function conversationContextKey(kind: string, id: string): string { + return `${kind.length}:${kind}${id}` +} diff --git a/packages/api/gateway/tsconfig.client.json b/packages/api/gateway/tsconfig.client.json new file mode 100644 index 0000000000..de257c951a --- /dev/null +++ b/packages/api/gateway/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json new file mode 100644 index 0000000000..5c12e43cc2 --- /dev/null +++ b/packages/api/gateway/tsconfig.host.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../client/connection/tsconfig.host.json" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/api/gateway/tsconfig.json b/packages/api/gateway/tsconfig.json index fea39663f7..2eca820546 100644 --- a/packages/api/gateway/tsconfig.json +++ b/packages/api/gateway/tsconfig.json @@ -1,27 +1,11 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../../vendor/cosmokit" + "path": "./tsconfig.host.json" }, { - "path": "../../../vendor/cordis" - }, - { - "path": "../../support/invariants" - }, - { - "path": "../../client/connection" - }, - { - "path": "../../typert/type-meta" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 256258c668..8f28b83a6e 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -15,7 +15,10 @@ "path": "../../../vendor/cordis" }, { - "path": "../gateway" + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../credentials/credentials" diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json new file mode 100644 index 0000000000..91ec8b42c7 --- /dev/null +++ b/packages/client/connection/tsconfig.client.json @@ -0,0 +1,49 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/api-path.ts", + "src/client/api.ts", + "src/client/connection.ts", + "src/client/fixture.ts", + "src/client/index.ts", + "src/client/random-uuid.ts", + "src/client/rpc.ts", + "src/client/web-api-client.ts", + "src/loopback-hostname.ts", + "src/rpc.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../host/apiproxy" + }, + { + "path": "../../interaction/commands" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../util/brand" + } + ] +} diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json new file mode 100644 index 0000000000..dca9b1a1b2 --- /dev/null +++ b/packages/client/connection/tsconfig.host.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/api-path.ts", + "src/api-request-trust.ts", + "src/http-bridge.ts", + "src/index.ts", + "src/invariant.ts", + "src/loopback-hostname.ts", + "src/rpc-host.ts", + "src/rpc.ts", + "src/websocket-downlink.ts" + ], + "references": [ + { + "path": "../../attachment/attachment" + }, + { + "path": "../../host/apiproxy" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 7bf86ffefe..2eca820546 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -1,46 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "types": ["node"] - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../attachment/attachment" + "path": "./tsconfig.host.json" }, { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../interaction/commands" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../host/apiproxy" - }, - { - "path": "../../host/webserver" - }, - { - "path": "../../interaction/user-approval" - }, - { - "path": "../../interaction/user-interaction" - }, - { - "path": "../../support/invariants" + "path": "./tsconfig.client.json" } - ], - "exclude": [ - "**/*.legacy.*" ] } diff --git a/tsconfig.client.json b/tsconfig.client.json index a054cb8751..9e2a7778d7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,10 +19,7 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", - "packages/host/directory-picker-browse/tests/**/*.ts", - "packages/host/directory-picker-browse/tests/**/*.tsx", - "packages/host/directory-picker-native/tests/**/*.ts", - "packages/host/directory-picker-native/tests/**/*.tsx", + "packages/api/gateway/tests/client.spec.ts", "packages/client/tsdown.client.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" @@ -32,11 +29,6 @@ // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, - // Dual-face host leaf: the node half is the native picking backend, the - // browser half registers the picking flow into ui-workspace's slot — - // client-side Context merges keep it out of the host program. - { "path": "./packages/host/directory-picker-native" }, - { "path": "./packages/host/directory-picker-browse" }, // Compaction seam: the client-runtime pin test value-imports the canonical // checkpoint const from the cordis-free dsh-compact/checkpoint leaf and // deliberately never loads the dsh-compact package root or the host-side @@ -51,9 +43,12 @@ { "path": "./packages/client/web-react" }, { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, - { "path": "./packages/client/connection" }, + { "path": "./packages/client/connection/tsconfig.client.json" }, + // The carrier's node-half spec rides this aggregate's package test glob, so + // its Host face is referenced here too — the mirror of the webserver leaf. + { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/api/gateway" }, + { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, @@ -69,6 +64,8 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-task" }, + { "path": "./packages/client/ui-directory-picker" }, + { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-agent-preset" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 90a254f72a..ef02fc557f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -89,8 +89,7 @@ ], "exclude": [ "packages/client/**", - "packages/host/directory-picker-browse/**", - "packages/host/directory-picker-native/**", + "packages/api/gateway/tests/client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" @@ -120,7 +119,7 @@ { "path": "./packages/core/scope" }, { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/api/gateway" }, + { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, @@ -270,11 +269,6 @@ { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-auto" }, - // Dual-face backend leaves stay client-registered (their tests and client - // halves are excluded above); these references only let the adaptive - // chooser's composition test import each backend's NODE entry, whose - // declarations carry no client-side Context merge — the mirror of the - // client aggregate's webserver reference. { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, From 159d102f997a9dbe246cf167adfb5c9f29f1675d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:11:26 +0800 Subject: [PATCH 23/46] refactor(client): name one assembly package for the carrier types A business package imported the Remote assembly for `ctx.remote` and the Connection plugin for the wire types it passes around. The assembly now re-exports the carrier's Client-facing types, so a business package names one package. The re-export is type-only: the carrier's runtime values keep their own module edge, since inlining them here would duplicate the carrier inside the assembly bundle. Four surfaces that had no Remote assembly dependency declare one now. --- packages/client/ui-agent-preset/src/client/index.ts | 2 +- .../client/ui-agent-preset/src/client/seat-store.ts | 2 +- .../client/ui-agent-preset/src/client/section-store.ts | 2 +- .../ui-agent-preset/src/client/settings-store.ts | 2 +- .../client/ui-agent-preset/tests/section-store.spec.ts | 2 +- .../ui-agent-preset/tests/settings-store.spec.ts | 2 +- packages/client/ui-agent-preset/tsconfig.json | 3 --- packages/client/ui-model/package.json | 2 -- packages/client/ui-model/src/client/ModelSelect.tsx | 2 +- packages/client/ui-model/src/client/directory.ts | 2 +- packages/client/ui-model/src/client/index.ts | 5 ++--- packages/client/ui-model/src/client/service.ts | 2 +- packages/client/ui-model/src/client/slots.ts | 2 +- packages/client/ui-model/tests/browser-plugin.spec.ts | 2 +- packages/client/ui-model/tests/model-select.spec.tsx | 2 +- packages/client/ui-model/tsconfig.json | 4 ++-- .../client/ui-models/src/client/CustomProviderCard.tsx | 2 +- .../client/ui-models/src/client/ModelListEditor.tsx | 2 +- packages/client/ui-models/src/client/ModelsSection.tsx | 2 +- .../client/ui-models/src/client/ProviderEditor.tsx | 2 +- packages/client/ui-models/src/client/index.ts | 2 +- packages/client/ui-models/src/client/store.ts | 2 +- packages/client/ui-models/tests/components.spec.tsx | 2 +- .../client/ui-models/tests/onboarding-dialog.spec.tsx | 2 +- packages/client/ui-models/tests/provider-form.spec.tsx | 2 +- packages/client/ui-models/tests/readiness.spec.ts | 2 +- packages/client/ui-models/tests/store.spec.ts | 2 +- packages/client/ui-models/tsconfig.json | 3 --- packages/client/ui-permission/src/client/index.ts | 2 +- .../client/ui-permission/src/client/settings-store.ts | 2 +- .../client/ui-permission/tests/permission-row.spec.tsx | 2 +- .../client/ui-permission/tests/settings-store.spec.ts | 2 +- packages/client/ui-permission/tsconfig.json | 3 --- packages/client/ui-question/package.json | 10 ++++++---- .../client/ui-question/src/client/contract/slots.ts | 2 +- .../ui-question/tests/plan-review-panel.spec.tsx | 2 +- .../ui-question/tests/question-composer.spec.tsx | 2 +- packages/client/ui-question/tsconfig.json | 4 ++-- .../client/ui-settings-general/src/client/index.ts | 2 +- .../src/client/settings-document-store.ts | 2 +- .../ui-settings-general/src/client/welcome-store.ts | 2 +- .../tests/settings-document-store.spec.ts | 2 +- .../ui-settings-general/tests/welcome-store.spec.ts | 2 +- packages/client/ui-settings-general/tsconfig.json | 3 --- packages/client/ui-settings/package.json | 2 -- .../client/ui-settings/src/client/settings-scope.ts | 4 ++-- .../client/ui-settings/tests/settings-scope.spec.ts | 2 +- packages/client/ui-settings/tsconfig.json | 5 +---- packages/client/ui-skill/package.json | 6 ++---- packages/client/ui-skill/src/client/index.ts | 5 ++--- packages/client/ui-skill/tsconfig.json | 4 ++-- packages/client/ui-tool/package.json | 6 ++++-- packages/client/ui-tool/tests/diff-card.spec.tsx | 2 +- packages/client/ui-tool/tests/read-card.spec.tsx | 2 +- packages/client/ui-tool/tests/search-card.spec.tsx | 2 +- packages/client/ui-tool/tests/terminal-card.spec.tsx | 2 +- packages/client/ui-tool/tests/web-card.spec.tsx | 2 +- packages/client/ui-tool/tsconfig.json | 3 +++ 58 files changed, 69 insertions(+), 85 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 2ec44b48bc..f327479660 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -11,7 +11,7 @@ * before-the-fact, while the header only reports what a session already runs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index ab973ec5b5..c91e77bbd5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -10,7 +10,7 @@ * deployment default again, matching the workspace picker beside it. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SessionId, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index a6f3cb4d62..0499d24a3b 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,7 +14,7 @@ * more than the row it targeted. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index c9499c40a9..4f4e26bdf8 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -7,7 +7,7 @@ * namespace's `default` field, which is what the host resolves at creation. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' /** The agent-preset settings namespace on the host wire. */ diff --git a/packages/client/ui-agent-preset/tests/section-store.spec.ts b/packages/client/ui-agent-preset/tests/section-store.spec.ts index 805a4ca8ae..dc62f74aff 100644 --- a/packages/client/ui-agent-preset/tests/section-store.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts index fc36dde066..0a98138233 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, } from '../src/client/settings-store.ts' diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index ea7292b023..e00a5ce5f8 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 09e4b5eb85..fb53792f04 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -57,7 +57,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "clsx": "^2.1.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -74,7 +73,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "clsx": "^2.1.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 7be74a90d7..e53001da89 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -16,7 +16,7 @@ import { type KeyboardEvent, type FocusEvent, } from 'react' import clsx from 'clsx' -import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, IconWarningOutline16, Toast, diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index 1555a2df43..b3eefb1a34 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -7,7 +7,7 @@ */ import type { IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index f883b16659..900e090b64 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -11,9 +11,8 @@ * neither entry because those Agent-bound RPCs would activate persisted * history outside the direct-parent continuation path. */ -import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-client-connection/client' -// Type-only: pulls the forwarded Host-event face and ctx.remote merge. -import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. +import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index 9653d8ab5a..85eeb3a89f 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -14,7 +14,7 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { ModelDirectory } from './directory.ts' diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts index e3e3957726..91124a81f6 100644 --- a/packages/client/ui-model/src/client/slots.ts +++ b/packages/client/ui-model/src/client/slots.ts @@ -4,7 +4,7 @@ * entry; this package only contributes the single occupant, so no SlotMap * merge lives here. */ -import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ModelDirectoryState } from './directory.ts' diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index c21a83b0ce..d9e0efec47 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -14,7 +14,7 @@ import { createScope } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index 4f6a37d151..ea9c2f7abb 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json index 80165da01f..52de20e7ed 100644 --- a/packages/client/ui-model/tsconfig.json +++ b/packages/client/ui-model/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index f3c43fcc5a..617c4450d5 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -23,7 +23,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' diff --git a/packages/client/ui-models/src/client/ModelListEditor.tsx b/packages/client/ui-models/src/client/ModelListEditor.tsx index b2966568ae..6d88484ac5 100644 --- a/packages/client/ui-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-models/src/client/ModelListEditor.tsx @@ -16,7 +16,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index ed3a3c9ad8..1eba48903e 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -12,7 +12,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index e72941f2d9..079eafd33f 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -23,7 +23,7 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client' +import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, } from '@deepseek-ai/dsh-client-schema-form' diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 079318c4d2..a4641fdc95 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -6,7 +6,7 @@ * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 95db7e6787..9cc2cb7c77 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -8,7 +8,7 @@ import type { ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 4d7086bd99..b1582a5fb8 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 772e14aaba..2aea95b705 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -2,7 +2,7 @@ /** First-run DeepSeek prompt behavior over the shared Models join. */ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index bd59ab0740..246c7d64b1 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index f01ab75930..8647a2da83 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -1,6 +1,6 @@ /** Pure official-DeepSeek readiness projection over the shared Models join. */ import { describe, expect, it } from 'vitest' -import type { CredentialView } from '@deepseek-ai/dsh-client-connection/client' +import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client' import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts' import { deepSeekReadiness } from '../src/client/store.ts' diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts index 5a1f340a0d..8de35335c6 100644 --- a/packages/client/ui-models/tests/store.spec.ts +++ b/packages/client/ui-models/tests/store.spec.ts @@ -1,6 +1,6 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ import { describe, expect, it } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { messageOf, ModelsSettingsStore } from '../src/client/store.ts' let nextRpc = 0 diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json index 71f8e9cc98..112dbd42d4 100644 --- a/packages/client/ui-models/tsconfig.json +++ b/packages/client/ui-models/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../connection" - }, { "path": "../schema-form" }, diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 1e9a9486c0..d81594c33b 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -13,7 +13,7 @@ * The General-settings row separately writes the default preset for sessions * created later through the host Settings API. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings slot types (this package registers a General row). diff --git a/packages/client/ui-permission/src/client/settings-store.ts b/packages/client/ui-permission/src/client/settings-store.ts index 7d4bf864c1..61ab03fee5 100644 --- a/packages/client/ui-permission/src/client/settings-store.ts +++ b/packages/client/ui-permission/src/client/settings-store.ts @@ -6,7 +6,7 @@ import type { IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx index cb6c69ac09..8372075937 100644 --- a/packages/client/ui-permission/tests/permission-row.spec.tsx +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { en } from '../src/client/locales.ts' import { PermissionSettingsController } from '../src/client/settings-store.ts' diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.spec.ts index 8ee09914e3..cfd191aa6e 100644 --- a/packages/client/ui-permission/tests/settings-store.spec.ts +++ b/packages/client/ui-permission/tests/settings-store.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, } from '../src/client/settings-store.ts' diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index 18cb5ff3da..d14be97980 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index cb74154ff0..9365417bda 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -53,19 +53,21 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@types/react": "~18.3.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index e8bada18b0..c37351578e 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -11,7 +11,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots // entry) into every program that sees this contract, so PropsRuntime resolves. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client' +import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client' /** The pending question carrier the owner dispatches into the composer slot. */ export type QuestionWait = PendingWait<'question'> diff --git a/packages/client/ui-question/tests/plan-review-panel.spec.tsx b/packages/client/ui-question/tests/plan-review-panel.spec.tsx index be22597a04..2abbe85844 100644 --- a/packages/client/ui-question/tests/plan-review-panel.spec.tsx +++ b/packages/client/ui-question/tests/plan-review-panel.spec.tsx @@ -9,7 +9,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts' diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 91475f43e4..9f3fce1ab4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -5,7 +5,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index a6400f7c84..3913ac0e9c 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 1c207efc76..3a868dc3b4 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,7 @@ * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: the settings slot declarations plus the ctx.settingsScope Context diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index eb1d9590b9..dde604d5ae 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,6 +1,6 @@ /** State owner for the optional local settings-document action. */ -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' /** Browser state of the Host-owned settings document. */ diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index c95c9e46d8..48f96bcb94 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,6 +1,6 @@ /** Welcome-notice state, durable when the browser may use Host settings. */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts index 9be3cf3252..b514da85da 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' function response(hasDocument = false): RpcResponse<{ diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts index 04f2608763..bfa5e16999 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts' import { diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index a4535df785..3f8200e137 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../ui-slots" }, - { - "path": "../connection" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d1da7f46a2..a46905c440 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -46,7 +46,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -58,7 +57,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 84591802c1..241ca86ae9 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -9,7 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, -} from '@deepseek-ai/dsh-client-connection/client' +} from '@deepseek-ai/dsh-api-remotes/client' import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, @@ -24,7 +24,7 @@ import { // `$on` and its key face without dragging a build artifact in. The runtime // `remote` injection belongs to whoever calls bindSettingsScope: the // subscription is registered on the caller's own context. -import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-remotes/types' // The forwarded event's own declaration: `$on`'s key face is // `Extract`, so the allowlist alone resolves to diff --git a/packages/client/ui-settings/tests/settings-scope.spec.ts b/packages/client/ui-settings/tests/settings-scope.spec.ts index 88baeab83b..3aed7c7042 100644 --- a/packages/client/ui-settings/tests/settings-scope.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts' diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 193107b1d2..4fa9f78ab8 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -17,14 +17,11 @@ { "path": "../runtime" }, - { - "path": "../connection" - }, { "path": "../schema-form" }, { - "path": "../../api/gateway" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../../settings/settings" diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 82f80bccca..06df0c7b4a 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -51,12 +51,11 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -65,14 +64,13 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 5511398234..a1e7527292 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -29,9 +29,8 @@ * This browser half also owns the `skill` keyed toolview: a replay-stable * accent row derived only from each logged call/result slice. */ -import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' -// Type-only: pulls the forwarded Host-event face and ctx.remote merge. -import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. +import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index b1d9835eee..8ea4ba311a 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -9,10 +9,10 @@ ], "references": [ { - "path": "../../../vendor/cordis" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../connection" + "path": "../../../vendor/cordis" }, { "path": "../locale" diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 398831749b..23bcbb3a04 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -48,16 +48,19 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -69,7 +72,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 1726c81136..c37b5bb540 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -13,7 +13,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index ae719173d5..b86b17924b 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -18,7 +18,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 3ff068b3c9..16e127c7ca 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -15,7 +15,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index dd39b8bc88..cb00ea4b93 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -13,7 +13,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 04535a0954..cc46918694 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -16,7 +16,7 @@ import { import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 17516295fb..7183ad66d2 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, From df97e4ce2d771d64655f7c33842dc8b612b3a25b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:42:32 +0800 Subject: [PATCH 24/46] fix: dep --- pnpm-lock.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62e3dc5898..21f2f8d4b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2352,9 +2352,6 @@ importers: packages/client/ui-model: devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2843,9 +2840,6 @@ importers: packages/client/ui-skill: devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes From 92e817bf38dc80aada927cbf450e4f9e2b6fbb1d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:05:09 +0800 Subject: [PATCH 25/46] chore(lint): satisfy the trailing-comma rule and drop dead oxlint directives The Gateway client spec's synthetic Remote namespace now resolves under both analyzers, so its typescript/no-unsafe-call suppressions report as unused. --- packages/api/gateway/tests/client.spec.ts | 4 ---- packages/api/remotes/src/client/index.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index cefdd94023..a268206d3f 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -226,9 +226,6 @@ describe('Client TypeRT API', () => { descriptors: [maybeDescriptor()], }) - // The analyzers disagree on the key-remapped namespace projection: tsc - // resolves this method, oxlint reads it as an error type. - // oxlint-disable-next-line typescript/no-unsafe-call await expect(ctx.remote.probe.maybe(undefined)).resolves.toBeUndefined() expect(call).toHaveBeenNthCalledWith( 1, @@ -237,7 +234,6 @@ describe('Client TypeRT API', () => { { args: {} }, expect.any(AbortSignal), ) - // oxlint-disable-next-line typescript/no-unsafe-call await expect(ctx.remote.probe.maybe(null)).resolves.toBeNull() expect(call).toHaveBeenNthCalledWith( 2, diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 7cc59cffb0..c08190ddc1 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -33,7 +33,7 @@ export type { RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView, - WorkspaceId, WorkspaceView + WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' declare module '@deepseek-ai/cordis' { From 51b5f5b565b3170707f00be3e2ea590d59a65446 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:14:36 +0800 Subject: [PATCH 26/46] test(picker): cover the node halves of the split directory-picker faces --- .../tests/client-flow.spec.tsx | 9 +++++++++ .../ui-directory-picker/tests/client-flow.spec.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx index d501fdfce2..98d2611e01 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx @@ -7,6 +7,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' +import { apply as nodeApply } from '../src/index.ts' afterEach(cleanup) @@ -225,3 +226,11 @@ describe('directory-picker-native client half', () => { expect(opened.container.innerHTML).toBe('') }) }) + +describe('directory-picker-native node half', () => { + // The invariant companion is mounted by the vitest-wide invariant host on + // every Context this suite creates; its registration is covered there. + it('the node apply is an inert loader seat', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-directory-picker/tests/client-flow.spec.tsx b/packages/client/ui-directory-picker/tests/client-flow.spec.tsx index 69ea819564..4bc86bf5eb 100644 --- a/packages/client/ui-directory-picker/tests/client-flow.spec.tsx +++ b/packages/client/ui-directory-picker/tests/client-flow.spec.tsx @@ -9,6 +9,7 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { BrowseDirectoryFlow } from '../src/client/flow.ts' +import { apply as nodeApply } from '../src/index.ts' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -221,3 +222,11 @@ describe('directory-picker-browse client half', () => { expect(view.container.innerHTML).toBe('') }) }) + +describe('directory-picker-browse node half', () => { + // The invariant companion is mounted by the vitest-wide invariant host on + // every Context this suite creates; its registration is covered there. + it('the node apply is an inert loader seat', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) From ea791827f4664b469652225f8d2fbcbb4876470a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:23:04 +0800 Subject: [PATCH 27/46] test(typert): reject a lookup parameter that accepts undefined --- packages/typert/registry/tests/typert.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 1bc4b65cb1..69e6497c2e 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -508,6 +508,17 @@ describe('TypertRegistry', () => { ...invocation(), parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + acceptsUndefined: true, + codec: { mode: 'src-json' }, + }], + }, 'cannot accept undefined'], [{ ...invocation(), parameters: [{ From 03f88e3c3d9df89da29641ed3b42f23ce791e4f8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:25:12 +0800 Subject: [PATCH 28/46] test(gateway): report a business rejection under an aborted carrier as cancelled --- packages/api/gateway/tests/gateway.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index c6f3a59768..8bc7817c95 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -1011,6 +1011,24 @@ describe('TypertGatewayService', () => { error: { code: 'internal', message: 'non-error failure', details: {} }, }) + // A business rejection observed while the carrier signal is already aborted + // is the caller's cancellation, not an internal gateway fault. + const cancelledCall = new AbortController() + cancelledCall.abort(new Error('client disconnected')) + service.businessError = new Error('fixture business failure') + await expect(handler( + 'goals/fail', + { args: { request: null } }, + cancelledCall.signal, + )).resolves.toEqual({ + ok: false, + error: { + code: 'cancelled', + message: 'Remote invocation "goals/fail" was aborted', + details: {}, + }, + }) + await gatewayFiber.dispose() expect(connection.handler).toBeUndefined() }) From 92e0e3377fb08d7008d36d45c002b452e12d982e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:45:10 +0800 Subject: [PATCH 29/46] test: finish the Remote-result and picker-split test migrations Every generated Remote method resolves to `RemoteResult`, so the Gateway client spec asserts the ok and error branches instead of the unwrapped value and a throw, and the generator fixtures declare the wrapper in the consumer face they typecheck. The RPC-failure test splits into the Host error carried verbatim in the error branch plus a transport throw folded into it. The runtime client, ui-command and ui-plan benches answer the generated commands Remote through its result branches and provide the `remote.commands` namespace their plugins now inject; the ui-command bench also serves the `$on` the service subscribes on construction. The directory-picker chooser mounts a backend and its surface as a pair, so the real-Loader composition serves both surface packages and asserts each entry arrives and leaves with its backend. --- packages/api/gateway/tests/client.spec.ts | 73 ++++++++++++------- .../client/runtime/tests/client-apply.spec.ts | 3 +- .../client/runtime/tests/wire-events.spec.ts | 3 +- .../ui-command/tests/browser-plugin.spec.ts | 4 +- .../client/ui-command/tests/service.spec.ts | 45 +++++++++--- .../ui-plan/tests/browser-plugin.spec.ts | 13 ++-- .../tests/loader-composition.spec.ts | 22 +++++- .../fixtures/remote-model/type-meta.d.ts | 10 +++ .../generator/tests/remote-model.spec.ts | 29 ++++---- 9 files changed, 138 insertions(+), 64 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index a268206d3f..409db48070 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + RemoteResult, TypeRTClientRemote, TypeRTContext, TypeRTRemoteScopeApi, @@ -46,16 +47,18 @@ declare module '@deepseek-ai/dsh-type-meta' { agentId: string, request: { readonly objective: string }, signal?: AbortSignal, - ) => Promise<{ readonly ref: string }> - 'probe/maybe': (value: string | null | undefined) => Promise + ) => Promise> + 'probe/maybe': (value: string | null | undefined) => Promise> } interface TypeRTRemoteScopeMap { 'fixture:probe/create': ( request: { readonly objective: string }, signal?: AbortSignal, - ) => Promise<{ readonly ref: string }> - 'fixture:probe/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + ) => Promise> + 'fixture:probe/rename': ( + request: { readonly objective: string }, + ) => Promise> } interface TypeRTRemoteNamespaceMap { @@ -182,7 +185,8 @@ describe('Client TypeRT API', () => { await assembly const retained = ctx.remote.probe.create - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })) + .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) expect(call).toHaveBeenCalledWith( '/api', 'probe/create', @@ -194,7 +198,7 @@ describe('Client TypeRT API', () => { 'agent-1', { objective: 'cancel me' }, callerAbort.signal, - )).resolves.toEqual({ ref: 'goal-1' }) + )).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) const combinedSignal = call.mock.calls.at(-1)?.[3] expect(combinedSignal).toBeInstanceOf(AbortSignal) expect(combinedSignal).not.toBe(callerAbort.signal) @@ -205,14 +209,20 @@ describe('Client TypeRT API', () => { await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: expect.stringContaining('rejected "result"') }, + }) await assembly.dispose() expect((ctx.remote as unknown as Record).probe).toBeUndefined() expect(ctx.get('remote.probe')).toBeUndefined() expect(ctx.get('probe')).toBe(businessProbe) expect(ctx.typert.remotes.list()).toEqual([]) - await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: expect.stringContaining('no longer mounted') }, + }) disposeBusinessProbe() }) @@ -226,7 +236,7 @@ describe('Client TypeRT API', () => { descriptors: [maybeDescriptor()], }) - await expect(ctx.remote.probe.maybe(undefined)).resolves.toBeUndefined() + await expect(ctx.remote.probe.maybe(undefined)).resolves.toStrictEqual({ ok: true, value: undefined }) expect(call).toHaveBeenNthCalledWith( 1, '/api', @@ -234,7 +244,7 @@ describe('Client TypeRT API', () => { { args: {} }, expect.any(AbortSignal), ) - await expect(ctx.remote.probe.maybe(null)).resolves.toBeNull() + await expect(ctx.remote.probe.maybe(null)).resolves.toStrictEqual({ ok: true, value: null }) expect(call).toHaveBeenNthCalledWith( 2, '/api', @@ -260,7 +270,8 @@ describe('Client TypeRT API', () => { )) await assembly - await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' })) + .resolves.toEqual({ ok: true, value: { ref: 'goal-2' } }) expect(call).toHaveBeenCalledWith( '/api', 'probe/create', @@ -289,7 +300,8 @@ describe('Client TypeRT API', () => { )) await assembly - await expect(agentCtx.remote.probe.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.probe.rename({ objective: 'land' })) + .resolves.toEqual({ ok: true, value: { renamed: true } }) expect(call).toHaveBeenCalledWith( '/api', 'probe/rename', @@ -373,7 +385,8 @@ describe('Client TypeRT API', () => { package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.remote.probe.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.probe.rename({ objective: 'remounted' })) + .resolves.toEqual({ ok: true, value: { renamed: true } }) expect(call).toHaveBeenLastCalledWith( '/api', 'probe/rename', @@ -523,7 +536,10 @@ describe('Client TypeRT API', () => { await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) - await expect(invocation).rejects.toThrow('withdrawn during invocation') + await expect(invocation).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: expect.stringContaining('no longer mounted') }, + }) expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) @@ -560,7 +576,7 @@ describe('Client TypeRT API', () => { const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) const method = (ctx.remote.probe as unknown as Record Promise>).prototype - await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) + await expect(method?.('wire-value')).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() expect(Object.hasOwn(payload.args, '__proto__')).toBe(true) @@ -649,21 +665,26 @@ describe('Client TypeRT API', () => { await disposeReplacement() }) - it('throws RPC failures with the structured error as its cause', async () => { + it('delivers an RPC failure in the error branch with the Host error verbatim', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) - let failure: unknown - try { - await ctx.remote.probe.create('agent-1', { objective: 'ship' }) - } catch (error) { - failure = error - } - expect(failure).toBeInstanceOf(Error) - if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail') - expect(failure.message).toContain('internal: host failed') - expect(failure.cause).toBe(rpcError) + const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' }) + expect(outcome.ok).toBe(false) + if (outcome.ok) throw new Error('expected the Client API invocation to report a failure') + expect(outcome.error).toBe(rpcError) + }) + + it('folds a transport throw into the error branch', async () => { + const ctx = await bench(vi.fn() + .mockRejectedValue(new Error('carrier offline'))) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: expect.stringContaining('carrier offline') }, + }) }) it('owns each $on subscription in the calling fiber', async () => { diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 40b7461b37..ae1e20a45b 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -14,7 +14,7 @@ import type { ConversationNodeDefinition } from '../src/client/contract/conversa import { Session } from '../src/client/sessions/session.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' -import { FakeApiClient, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' interface Bench { ctx: Context @@ -45,6 +45,7 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.commands', fakeRemote().commands) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 73861b8003..20ff4c966d 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -13,7 +13,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' // key face and per-event listener signatures. import type {} from '@deepseek-ai/dsh-api-remotes/client' import * as RuntimeClient from '../src/client/index.ts' -import { FakeApiClient } from './fake-api.ts' +import { FakeApiClient, fakeRemote } from './fake-api.ts' /** * Compile-time face of `ctx.remote.$on`, asserted by type-checking this file @@ -74,6 +74,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('remote.commands', fakeRemote().commands) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index b413e8d79e..617399dab0 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -33,7 +33,9 @@ async function bench() { scopeOf: (c: Context) => scopeOf(c), }) const commandsRemote = { list: () => Promise.resolve([]) } - ctx.provide('remote', { commands: commandsRemote }) + // The service subscribes its cache-invalidation events on construction, so + // the Remote face needs `$on` even where this spec dispatches none. + ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} }) ctx.provide('remote.commands', commandsRemote) await ctx.plugin(SlotsService).await() ctx.slots.register({ diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 861bcdc0aa..c3413317c5 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -40,6 +40,28 @@ interface BenchOptions { addressed?: SessionId } +/** + * Fold one programmed answer into the generated Remote face's outcome: a + * resolved value is the ok branch, a rejection is the transport failure the + * carrier reports in the error branch instead of throwing at the caller. + * @param produce - the scripted answer for one Remote method. + * @returns the carried result the service reads. + */ +async function carried(produce: () => Promise) { + try { + return { ok: true as const, value: await produce() } + } catch (error) { + return { + ok: false as const, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } + } +} + async function bench(opts: BenchOptions = {}) { const ctx = new Context() const registered = new Map() @@ -50,21 +72,22 @@ async function bench(opts: BenchOptions = {}) { const commandsRemote = { list: async (sessionId: SessionId) => { listCalls.push({ sessionId }) - const value = await (opts.commands ?? (p => Promise.resolve({ - commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS, - })))({ sessionId }) - return { ok: true as const, value: value.commands } + return await carried(async () => { + const value = await (opts.commands ?? (p => Promise.resolve({ + commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS, + })))({ sessionId }) + return value.commands + }) }, execute: async (sessionId: SessionId, line: string) => { executeCalls.push({ sessionId, line }) - const fallback = (): Promise => Promise.resolve({ matched: true }) - const value = await (opts.execute ?? fallback)({ sessionId, line }) - return { - ok: true as const, - value: value.matched + return await carried(async () => { + const fallback = (): Promise => Promise.resolve({ matched: true }) + const value = await (opts.execute ?? fallback)({ sessionId, line }) + return value.matched ? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } } - : undefined, - } + : undefined + }) }, } ctx.provide('slash', { diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 1a412c06de..c60cf68928 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -26,7 +26,7 @@ async function bench() { children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, } as never, () => null) const execute = vi.fn((_sessionId: SessionId, _line: string) => - Promise.resolve({ commandId: 'c1', result: { kind: 'success' as const } })) + Promise.resolve({ ok: true, value: { commandId: 'c1', result: { kind: 'success' as const } } })) const commandsRemote = { execute } ctx.provide('remote', { commands: commandsRemote }) ctx.provide('remote.commands', commandsRemote) @@ -71,14 +71,15 @@ describe('ui-plan browser apply', () => { expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off') // Business failure folds to the composer-visible line: the generated method - // throws with the RPC failure as its cause. - b.execute.mockRejectedValueOnce(new Error('client api: commands/execute failed', { - cause: { code: 'session-not-found', message: 'gone', details: {} }, - })) + // reports the RPC failure in its error branch. + b.execute.mockResolvedValueOnce({ + ok: false, + error: { code: 'session-not-found', message: 'gone', details: {} }, + } as never) await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. - b.execute.mockResolvedValueOnce(undefined as never) + b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never) await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off') await fiber.dispose() diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index ed9bfd1e44..febb49065a 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -1,10 +1,10 @@ /** * REAL-composition coverage: a test-only cordis.yml booted through the * vendored Loader mounts the webserver row plus the adaptive chooser, and the - * assertions observe the durable outcome — which backend entry the chooser - * mounted into the Loader store, the capability the seam then serves, and - * that disposing the chooser removes the mounted entry again (HMR safety), - * joining the backend's own teardown before the disposer settles. + * assertions observe the durable outcome — which backend and surface entries + * the chooser mounted into the Loader store, the capability the seam then + * serves, and that disposing the chooser removes both mounted entries again + * (HMR safety), joining the backend's own teardown before the disposer settles. */ import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' @@ -20,6 +20,8 @@ import HttpServer from '@deepseek-ai/dsh-host-webserver' import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse' import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' +import * as BrowseSurface from '@deepseek-ai/dsh-client-ui-directory-picker' +import * as NativeSurface from '@deepseek-ai/dsh-client-ui-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' const renameControl = vi.hoisted(() => ({ @@ -48,6 +50,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto' const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native' const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' +const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native' +const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker' let root: string | undefined let fakeBin: string | undefined @@ -92,6 +96,8 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx [AUTO, DirectoryPickerAuto], [NATIVE, NativeDirectoryPicker], [BROWSE, BrowseDirectoryPicker], + [NATIVE_SURFACE, NativeSurface], + [BROWSE_SURFACE, BrowseSurface], ]) context.loader.internal = { version: 'v2', @@ -142,7 +148,9 @@ describe('real Loader composition', () => { .map(entry => entry.options.name) expect(unloaded).toEqual([]) expect(entryNames(ctx)).toContain(NATIVE) + expect(entryNames(ctx)).toContain(NATIVE_SURFACE) expect(entryNames(ctx)).not.toContain(BROWSE) + expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE) const picker = ctx.get('directoryPicker') as DirectoryPicker expect(picker.capability().kind).toBe('native') // The mounted row lives in the Loader's in-memory root tree only — the @@ -155,6 +163,7 @@ describe('real Loader composition', () => { const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! await autoEntry.fiber!.dispose() expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) expect(ctx.get('directoryPicker')).toBeUndefined() // Self-disposing an include-tree entry persists `disabled: true` (loader // behavior, not the chooser's); await that debounced write so it cannot @@ -170,7 +179,9 @@ describe('real Loader composition', () => { const { ctx } = await loadComposition('127.0.0.1') expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).toContain(BROWSE_SURFACE) expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) const picker = ctx.get('directoryPicker') as DirectoryPicker expect(picker.capability().kind).toBe('browse') }) @@ -180,7 +191,9 @@ describe('real Loader composition', () => { const { ctx } = await loadComposition('0.0.0.0') expect(entryNames(ctx)).toContain(BROWSE) + expect(entryNames(ctx)).toContain(BROWSE_SURFACE) expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) }) it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => { @@ -193,6 +206,7 @@ describe('real Loader composition', () => { renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() expect(entryNames(ctx)).not.toContain(NATIVE) + expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) // Same self-dispose persistence as above: let the write land before teardown. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true') expect(renameControl.injectedFailures).toBe(1) diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 707dc84ce9..7fbc5bd656 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -13,6 +13,16 @@ declare module '@deepseek-ai/dsh-type-meta' { export interface TypeRTRemoteMap {} export interface TypeRTRemoteScopeMap {} + export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object + } + + export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + export type TypeRTRemoteNamespace = { [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` ? Method diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 567feaae4e..443d5997c3 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -114,15 +114,15 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise>", ) expect(artifact?.remote?.dts).toContain( - "'agent:goals/rename': (request: RenameGoalRequest) => Promise", + "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) const remoteJs = artifact?.remote?.js @@ -172,13 +172,13 @@ export type {`, const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toContain( - "'goals/maybe': (value: string | undefined) => Promise", + "'goals/maybe': (value: string | undefined) => Promise>", ) - expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise") + expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise>") // An explicit `T | undefined` stays a required argument; only authored // optionality lets a consumer omit the field. expect(artifact?.remote?.dts).not.toContain('value?: string') - expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise") + expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise>") const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript') @@ -256,7 +256,7 @@ export type GenericResult = { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toContain( - "'goals/dispatch': (request: GenericRequest) => Promise", + "'goals/dispatch': (request: GenericRequest) => Promise>", ) const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') @@ -306,7 +306,7 @@ export interface BoxPayload { const [artifact] = new WorkspaceTypertGenerator(root).generate() expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/) - expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>') + expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>>') assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) @@ -326,7 +326,7 @@ export interface BoxPayload { )) const [artifact] = new WorkspaceTypertGenerator(root).generate() - expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise") + expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise>") assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) @@ -636,6 +636,7 @@ function assertRemoteConsumerTypechecks( const consumerSource = ` import remote from '@fixture/remote/remote' import type { + RemoteResult, TypeRTRemoteContribution, TypeRTRemoteScopeMap, TypeRTRemoteMap, @@ -647,12 +648,12 @@ const contribution: TypeRTRemoteContribution = remote declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create'] declare const rename: TypeRTRemoteScopeMap['agent:goals/rename'] -const created: Promise = create('agent-1', { title: 'ship' }) -const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) -const createdScoped: Promise = createScoped({ title: 'ship' }) -const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +const created: Promise> = create('agent-1', { title: 'ship' }) +const cancellable: Promise> = create('agent-1', { title: 'ship' }, new AbortController().signal) +const createdScoped: Promise> = createScoped({ title: 'ship' }) +const renamed: Promise> = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { remote: TypeRTRemoteNamespaceMap } -const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) +const navigated: Promise> = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable From 380b8a18a8f4a80426b383cf2496b9991cc746ad Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:45:36 +0800 Subject: [PATCH 30/46] fix(commands): declare the zod runtime dependency of the generated faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exporting `./typert` and `./remote` ships two generated modules that `import { z } from 'zod'`, and the package declared no runtime dependency at all. Under pnpm's isolated layout nothing resolves zod for it — there is no root `node_modules/zod` to walk up to — so loading the plugin tree failed with ERR_MODULE_NOT_FOUND the moment a composition mounted the commands typert face. The two other packages exporting `./typert` both declare it; this matches them. --- packages/interaction/commands/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 1b377bebce..b654451b37 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -62,6 +62,9 @@ "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", From 160aec71159fac287acdca1e78841c34203be6c5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:46:30 +0800 Subject: [PATCH 31/46] chore(deps): record the commands zod dependency in the lockfile --- pnpm-lock.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21f2f8d4b1..074a1bac70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2250,9 +2250,6 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../test-runtime '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2711,6 +2708,10 @@ importers: version: 18.3.31 packages/client/ui-settings: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2718,9 +2719,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -4952,6 +4950,10 @@ importers: version: link:../../support/invariants packages/interaction/commands: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ From 3e41b38b39480f9a756097703d761ddcb8671177 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:50:23 +0800 Subject: [PATCH 32/46] test(gateway): fold a non-Error carrier throw into the client error branch --- packages/api/gateway/tests/client.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 409db48070..788bdbbd78 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -687,6 +687,21 @@ describe('Client TypeRT API', () => { }) }) + it('folds a carrier throw that is not an Error into the error branch', async () => { + const ctx = await bench(vi.fn() + .mockRejectedValue('carrier exploded')) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ + ok: false, + error: { + code: 'internal', + message: 'client api: probe/create failed: carrier exploded', + details: {}, + }, + }) + }) + it('owns each $on subscription in the calling fiber', async () => { const { ctx, client } = await benchFiber(vi.fn()) const seen: string[] = [] From 7a9d7d265753daa4006b613659cbcbbb13133ee8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:00:02 +0800 Subject: [PATCH 33/46] chore(hygiene): restore the cordis peers and teach knip the split faces `@deepseek-ai/cordis` returns to the peer and dev dependencies of `dsh-client-ui-model` and `dsh-client-ui-skill`; every harness package declares it, and the client type-assembly rename dropped it from both. The commands manifest ships `src` alongside its generated typert faces, matching the `./src/*` export it already declares. knip gains the two directory-picker surface workspaces, whose specs are `.tsx` and matched no default pattern, and ignores `zod` in the commands workspace: that dependency belongs to the generated Remote and Host faces in `lib/`, which knip never scans. The unused `dsh-client-test-runtime` and `dsh-client-connection` dev dependencies are gone; the picker surface's own spec never imported the former, and ui-settings reads the carrier's types through the Remote assembly now. --- knip.json | 22 +++++++++++++++++++ .../ui-directory-picker-native/package.json | 1 - packages/client/ui-model/package.json | 2 ++ packages/client/ui-settings/package.json | 1 - packages/client/ui-skill/package.json | 2 ++ packages/interaction/commands/package.json | 3 ++- pnpm-lock.yaml | 6 +++++ 7 files changed, 34 insertions(+), 3 deletions(-) diff --git a/knip.json b/knip.json index 1f3dcefea8..476641830f 100644 --- a/knip.json +++ b/knip.json @@ -151,6 +151,25 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-directory-picker": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, + "packages/client/ui-directory-picker-native": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.tsx" + ] + }, "packages/client/ui-deliverables": { "entry": [ "tests/**/*.spec.tsx" @@ -559,6 +578,9 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "zod" ] }, "packages/examples/jsonrpc-demo": { diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 1e0e666f71..f7a0a80bdf 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -53,7 +53,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index fb53792f04..09e4b5eb85 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "clsx": "^2.1.1", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -73,6 +74,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "clsx": "^2.1.1", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index a46905c440..95575dc666 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -58,7 +58,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 06df0c7b4a..e5d11fe0e1 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -71,6 +72,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index b654451b37..7b9ade9d26 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -50,7 +50,8 @@ "lib/typert.host.d.ts", "lib/typert.remote-client.js", "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map" + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 074a1bac70..2e71c12536 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2349,6 +2349,9 @@ importers: packages/client/ui-model: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2838,6 +2841,9 @@ importers: packages/client/ui-skill: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes From dffc0a9a15f423c63702b9e8f820f48f74aa8702 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:05:33 +0800 Subject: [PATCH 34/46] test(gateway): pin each folded carrier failure message verbatim Matching the full `RemoteFailure` keeps the assertion inside the typed result and drops the `expect.stringContaining` placeholders, whose `any` return the lint rule rejects on assignment. --- packages/api/gateway/tests/client.spec.ts | 32 +++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 788bdbbd78..067e254aec 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -209,9 +209,13 @@ describe('Client TypeRT API', () => { await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ok: false, - error: { code: 'internal', message: expect.stringContaining('rejected "result"') }, + error: { + code: 'internal', + message: 'client api: probe/create failed: client api: probe/create rejected "result"', + details: {}, + }, }) await assembly.dispose() @@ -219,9 +223,13 @@ describe('Client TypeRT API', () => { expect(ctx.get('remote.probe')).toBeUndefined() expect(ctx.get('probe')).toBe(businessProbe) expect(ctx.typert.remotes.list()).toEqual([]) - await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toMatchObject({ + await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({ ok: false, - error: { code: 'internal', message: expect.stringContaining('no longer mounted') }, + error: { + code: 'internal', + message: 'client api: Remote method probe/create is no longer mounted', + details: {}, + }, }) disposeBusinessProbe() }) @@ -536,9 +544,13 @@ describe('Client TypeRT API', () => { await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) - await expect(invocation).resolves.toMatchObject({ + await expect(invocation).resolves.toEqual({ ok: false, - error: { code: 'internal', message: expect.stringContaining('no longer mounted') }, + error: { + code: 'internal', + message: 'client api: Remote method probe/create is no longer mounted', + details: {}, + }, }) expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) @@ -681,9 +693,13 @@ describe('Client TypeRT API', () => { .mockRejectedValue(new Error('carrier offline'))) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ok: false, - error: { code: 'internal', message: expect.stringContaining('carrier offline') }, + error: { + code: 'internal', + message: 'client api: probe/create failed: carrier offline', + details: {}, + }, }) }) From 91e7b3d0866a741985ac6485cdc231873dc1bdf3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:12:32 +0800 Subject: [PATCH 35/46] test(apiproxy): drop the carrier-signal case for the removed command route `command.execute` no longer exists on the API Proxy, and the fake's own handler went with it, so the case only reached a 404 body. The same carrier behaviour is asserted on live routes by its `session.search`, `subagent.prompt` and `host.pickDirectory` siblings. --- packages/host/apiproxy/tests/fetch-carrier.spec.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 5ee9b54060..4ce3d084e0 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -505,20 +505,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into command.execute', async () => { - const handler = toFetchHandler(fakeApi()) - const controller = new AbortController() - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } }) - // The fake's /hang settles only when the invoke-level signal aborts: a - // completed response with the cancelled error proves req.signal reached it. - const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal })) - controller.abort() - const response = await pending - const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } - expect(parsed.rpcId).toBe('r-sig') - expect(parsed.result.error?.code).toBe('cancelled') - }) - it('propagates the carrier Request signal into session.search', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() From 6332bd351337d36ff4368930c5ccf70b590555f0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:53:22 +0800 Subject: [PATCH 36/46] fix: build --- apps/web/tests/schedule-after.e2e.ts | 3 +-- .../client/ui-plugin-config/tsconfig.json | 3 --- .../tests/loader-composition.spec.ts | 21 +++++++++++++++---- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 14b5a68059..93d4cc1598 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -9,7 +9,6 @@ import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { ScheduleId, createEveryScheduleRecord, @@ -26,7 +25,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, conversationContextKey, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url)) diff --git a/packages/client/ui-plugin-config/tsconfig.json b/packages/client/ui-plugin-config/tsconfig.json index 3e03d647f2..55347fa8b4 100644 --- a/packages/client/ui-plugin-config/tsconfig.json +++ b/packages/client/ui-plugin-config/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index febb49065a..dc39c2ae77 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -20,8 +20,6 @@ import HttpServer from '@deepseek-ai/dsh-host-webserver' import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse' import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' -import * as BrowseSurface from '@deepseek-ai/dsh-client-ui-directory-picker' -import * as NativeSurface from '@deepseek-ai/dsh-client-ui-directory-picker-native' import * as DirectoryPickerAuto from '../src/index.ts' const renameControl = vi.hoisted(() => ({ @@ -53,6 +51,21 @@ const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse' const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native' const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker' +/** + * Loader-visible stand-in for a client surface package: the surfaces belong to + * the Client program and publish browser entry points only, so a Host-face spec + * can neither name them in a static import nor resolve them from source. What + * the chooser owns is the mounting decision, which every case observes through + * the Loader store; the surface's own browser contributions belong to the + * assembled web coverage. + * + * @param name Surface package specifier the chooser mounts. + * @returns A function-plugin module the Loader can mount under that specifier. + */ +function surfaceModule(name: string): unknown { + return { name, apply: () => undefined } +} + let root: string | undefined let fakeBin: string | undefined let context: Context | undefined @@ -96,8 +109,8 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx [AUTO, DirectoryPickerAuto], [NATIVE, NativeDirectoryPicker], [BROWSE, BrowseDirectoryPicker], - [NATIVE_SURFACE, NativeSurface], - [BROWSE_SURFACE, BrowseSurface], + [NATIVE_SURFACE, surfaceModule(NATIVE_SURFACE)], + [BROWSE_SURFACE, surfaceModule(BROWSE_SURFACE)], ]) context.loader.internal = { version: 'v2', From 378141bf5da3de3a8bd217e44e5f3cfc0abc3abb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:20:45 +0800 Subject: [PATCH 37/46] fix: test --- knip.json | 6 ++ packages/client/ui-goal/src/client/index.ts | 18 ++--- packages/client/ui-goal/src/client/slots.ts | 12 ++-- .../ui-goal/tests/browser-plugin.spec.tsx | 67 ++++++++----------- .../client/ui-goal/tests/goalbar.spec.tsx | 16 ++--- .../host/directory-picker-auto/src/index.ts | 5 +- 6 files changed, 59 insertions(+), 65 deletions(-) diff --git a/knip.json b/knip.json index 476641830f..34ef90779a 100644 --- a/knip.json +++ b/knip.json @@ -91,6 +91,12 @@ "tests/**/*.ts" ] }, + "packages/host/directory-picker-auto": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-directory-picker", + "@deepseek-ai/dsh-client-ui-directory-picker-native" + ] + }, "packages/host/directory-picker-native": { "entry": [ "tests/**/*.spec.{ts,tsx}", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index f2f545d3c3..bfa1a283b5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -8,7 +8,6 @@ * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ -import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -41,13 +40,6 @@ const NS = 'goal' /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] -/** Narrow one Remote mutation's result to the fields the goal strip renders. */ -function settle(result: RemoteResult): GoalActionResult { - return result.ok - ? { ok: true } - : { ok: false, error: { code: result.error.code, message: result.error.message } } -} - /** * Client plugin body: the GoalBar dock entry with its mutation verbs. * @param ctx - client root context. @@ -74,7 +66,7 @@ export function apply(ctx: ClientContext): void { const noCurrentGoal: GoalActionResult = { ok: false, - error: { code: 'no-current-goal', message: 'no current goal to mutate' }, + error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} }, } ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ @@ -86,22 +78,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.edit(sessionId, ref, { objective })) + return await ctx.remote.goals.edit(sessionId, ref, { objective }) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.pause(sessionId, ref)) + return await ctx.remote.goals.pause(sessionId, ref) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.resume(sessionId, ref)) + return await ctx.remote.goals.resume(sessionId, ref) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(await ctx.remote.goals.clear(sessionId, ref)) + return await ctx.remote.goals.clear(sessionId, ref) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/src/client/slots.ts b/packages/client/ui-goal/src/client/slots.ts index 7a1e869afc..a0dc2a5063 100644 --- a/packages/client/ui-goal/src/client/slots.ts +++ b/packages/client/ui-goal/src/client/slots.ts @@ -7,10 +7,14 @@ * (callbacks from inject, live state from useProjection). */ -/** Settled outcome of one goal mutation, rendered inline by the strip. */ -export type GoalActionResult = - | { ok: true } - | { ok: false; error: { code: string; message: string } } +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' + +/** + * Settled outcome of one goal mutation, rendered inline by the strip. The + * strip renders the failure only — the mutated goal arrives through the + * projection — so the success value stays unread here. + */ +export type GoalActionResult = RemoteResult /** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */ export interface GoalBarActions { diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 793a6e3681..cac81a5d50 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -5,8 +5,8 @@ * conversation.input.dock, the inject face's four verbs read the CAS ref * from the session's CURRENT projected value at call time (no fence — the * Remote method's compare-and-set is the guard), a missing projection short-circuits - * to the no-current-goal error without touching the wire, and Remote errors - * map onto the inline-render result shape. Registration disposal rides the + * to the no-current-goal error without touching the wire, and a Remote failure + * reaches the strip verbatim. Registration disposal rides the * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ @@ -48,8 +48,7 @@ function makeProjection(revision = 3): GoalProjection { /** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */ async function bench(options: { projection?: GoalProjection | null | undefined - failWith?: { code: string; message: string } - rejectWith?: unknown + failWith?: { code: string; message: string; details: object } } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] @@ -57,12 +56,8 @@ async function bench(options: { function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test. - if ('rejectWith' in options) return Promise.reject(options.rejectWith) - if (options.failWith !== undefined) { - return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith })) - } - return Promise.resolve(value) + if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith }) + return Promise.resolve({ ok: true, value }) } } const ref = { id: 'g-1', revision: 3 } @@ -139,10 +134,13 @@ describe('ui-goal browser plugin', () => { const b = await bench({ projection: makeProjection(5) }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) - expect(await verbs.onEdit('New objective')).toEqual({ ok: true }) - expect(await verbs.onPause()).toEqual({ ok: true }) - expect(await verbs.onResume()).toEqual({ ok: true }) - expect(await verbs.onClear()).toEqual({ ok: true }) + // The strip forwards the Remote value verbatim; `answered` is the fake's + // reply, unrelated to the CAS ref the call carries. + const answered = { id: 'g-1', revision: 3 } + expect(await verbs.onEdit('New objective')).toEqual({ ok: true, value: { ref: answered } }) + expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: answered } }) + expect(await verbs.onResume()).toEqual({ ok: true, value: { ref: answered } }) + expect(await verbs.onClear()).toEqual({ ok: true, value: answered }) expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear']) const ref = { id: 'g-1', revision: 5 } expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }]) @@ -157,18 +155,22 @@ describe('ui-goal browser plugin', () => { const verbs = b.entry()!.inject!(sid('s1')) b.remountGoals() - expect(await verbs.onPause()).toEqual({ ok: true }) + expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: { id: 'g-1', revision: 3 } } }) expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) }) - it('settles every verb when the Remote namespace is temporarily absent', async () => { + it('rejects every verb once the Remote namespace is gone', async () => { const b = await bench({ projection: makeProjection() }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) b.unmountGoals() - for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { - expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + // A missing namespace is an assembly fault, not a call outcome: this plugin + // declares remote.goals in `inject`, so cordis disposes the dock entry along + // with the namespace. Only a React closure that outlived that disposal can + // reach these verbs, so no consumer-side guard renders it as an error. + for (const verb of [() => verbs.onEdit('x'), () => verbs.onPause(), () => verbs.onResume(), () => verbs.onClear()]) { + await expect(verb()).rejects.toThrow(TypeError) } expect(b.calls).toHaveLength(0) }) @@ -179,30 +181,17 @@ describe('ui-goal browser plugin', () => { await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { - expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } }) + expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} } }) } expect(b.calls).toHaveLength(0) } }) - it('maps a Remote error onto the inline-render shape', async () => { - const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) + it('forwards a Remote failure to the strip verbatim', async () => { + const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) - expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) - }) - - it.each([ - [new Error('connection closed'), 'connection closed'], - ['connection closed', 'goal mutation failed'], - [new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'], - [new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'], - [new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'], - ])('maps an unstructured rejection onto an internal error', async (rejection, message) => { - const b = await bench({ projection: makeProjection(), rejectWith: rejection }) - await b.fiber.await() - const verbs = b.entry()!.inject!(sid('s1')) - expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } }) + expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } }) }) it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { @@ -223,10 +212,10 @@ describe('GoalDock adapter', () => { const projection = makeProjection() const useProjection = vi.fn(() => projection) const actions: GoalBarActions = { - onEdit: () => Promise.resolve({ ok: true }), - onPause: () => Promise.resolve({ ok: true }), - onResume: () => Promise.resolve({ ok: true }), - onClear: () => Promise.resolve({ ok: true }), + onEdit: () => Promise.resolve({ ok: true, value: undefined }), + onPause: () => Promise.resolve({ ok: true, value: undefined }), + onResume: () => Promise.resolve({ ok: true, value: undefined }), + onClear: () => Promise.resolve({ ok: true, value: undefined }), } const t = makeTranslate(zh, commonZh) const dockProps = (up: () => GoalProjection | null | undefined) => diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx index 931c591000..a45387f048 100644 --- a/packages/client/ui-goal/tests/goalbar.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.spec.tsx @@ -30,10 +30,10 @@ function makeGoal(over: Partial = {}): GoalSnapshot { function makeActions() { return { - onEdit: vi.fn(() => Promise.resolve({ ok: true })), - onPause: vi.fn(() => Promise.resolve({ ok: true })), - onResume: vi.fn(() => Promise.resolve({ ok: true })), - onClear: vi.fn(() => Promise.resolve({ ok: true })), + onEdit: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), + onPause: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), + onResume: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), + onClear: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), } satisfies GoalBarActions } @@ -75,7 +75,7 @@ describe('GoalBar', () => { expect(actions.onClear).toHaveBeenCalledTimes(1) expect(clear.disabled).toBe(true) - await act(async () => { resolveClear({ ok: true }) }) + await act(async () => { resolveClear({ ok: true, value: undefined }) }) expect(container.firstChild).toBeNull() rerender() @@ -178,7 +178,7 @@ describe('GoalBar', () => { it('keeps the edit draft open and reports a failed save', async () => { const actions = makeActions() - actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } }) + actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision', details: {} } }) render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) @@ -191,12 +191,12 @@ describe('GoalBar', () => { it('reports resume and clear failures without hiding the goal', async () => { const actions = makeActions() - actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } }) + actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed', details: {} } }) const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)') - actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } }) + actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } }) rerender() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 0d836419a0..764af27cdc 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -42,7 +42,10 @@ export const BACKEND_PACKAGES: Record = { /** * Client surface package per resolved kind, mounted with its backend so one * resolved interaction still composes both faces. Declared as dependencies by - * every composing app for the same reason as {@link BACKEND_PACKAGES}. + * every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the + * specifier is referenced here — the packages belong to the Client program, so + * no import of them exists on this side and knip needs them ignored for this + * workspace. */ export const SURFACE_PACKAGES: Record = { native: '@deepseek-ai/dsh-client-ui-directory-picker-native', From 9b2925e50fb5ffaecd3518a906fcabf81521be0e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:01:20 +0800 Subject: [PATCH 38/46] fix: restore the credential-rejected branch and admit SRC absence by key Review follow-ups that are logic rather than documentation: - rpc.schema.ts had lost the credential-rejected branch while api-proxy.ts still returns that code, so a legitimate business error failed the client's response parse. Restore the branch and assert it. - rpc-schemas.spec.ts had lost the workspace list, archiveSession and insertSessionBefore cases along with the command schemas; those routes still ship, so restore their coverage. - An omitted SRC field is now recognized by an absent key instead of an undefined value, which makes the allowance assertExactArguments already granted reachable; an explicitly undefined field stays invalid input. A weak descriptor's undefined result rides the wire as an absent value, matching the envelope removal. - The chooser unmounts an already-created backend when the surface entry fails to load, and no longer reverses the captured id array in place. --- packages/api/gateway/src/index.ts | 9 +++++++ packages/api/gateway/tests/gateway.spec.ts | 13 ++++++++++ packages/host/apiproxy/src/api/rpc.schema.ts | 1 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 25 ++++++++++++++++++- .../host/directory-picker-auto/src/index.ts | 19 ++++++++++---- .../tests/loader-composition.spec.ts | 19 +++++++++++++- 6 files changed, 79 insertions(+), 7 deletions(-) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 9b7ba6f2b1..1a060979a0 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -176,6 +176,10 @@ export class TypertGatewayService extends Service implements TypertGateway { if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error) throw error } + // A weak descriptor declares no return type, so nothing returned is a void + // result and rides the wire as an absent value field. A strict descriptor + // keeps its schema: there, undefined has to be a declared result. + if (result === undefined && descriptor.result.mode !== 'strict') return result return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') } @@ -405,6 +409,11 @@ export class TypertGatewayService extends Service implements TypertGateway { args: Readonly>, endpoint: string, ): Promise { + // An absent field reached assertExactArguments' allowance, so this parameter + // takes undefined; a present-but-undefined field is not JSON-safe input and + // still fails decode. Lookup ids are never omissible, so absence here only + // ever belongs to a json parameter. + if (!Object.hasOwn(args, parameter.wire)) return undefined const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index 8bc7817c95..00914d971f 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -788,6 +788,19 @@ describe('TypertGatewayService', () => { }), 'input-invalid') }) + it('admits an omitted SRC field and hands the Host method undefined', async () => { + const { ctx, service } = await setup() + // A weak descriptor reads parameter names from the JavaScript signature and + // cannot see which are optional, so an absent field is admitted; the case + // above keeps an explicitly undefined field rejected. + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: {}, + })).resolves.toBeUndefined() + expect(service.calls).toContain('passthrough') + }) + it('rejects cyclic SRC input and non-JSON SRC results', async () => { const { ctx, service } = await setup() const cyclic: { self?: unknown } = {} diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 53f3c34ec2..177f0ffd29 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -61,6 +61,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), + z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 0e8ab81ec2..c8e81964b9 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,8 +20,11 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, + workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' @@ -73,6 +76,8 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') + // The credentials producer still emits this code, so the branch has to stay. + expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -344,11 +349,29 @@ describe('workspace domain schemas', () => { createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', } - it('validates ids and the view row', () => { + it('validates ids, the view row, and list request/value', () => { expect(workspaceIdSchema.parse('w1')).toBe('w1') expect(() => workspaceIdSchema.parse('')).toThrow() expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() + expect(workspaceListRequestSchema.parse({})).toEqual({}) + expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) + expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() + }) + + it('archiveSession request/value carry the id and the full updated set', () => { + expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() + expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) + .toEqual(['s1', 's2']) + expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() + }) + + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') }) it('create requires a path', () => { diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 764af27cdc..4f1ee77b98 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -72,11 +72,8 @@ export async function apply(ctx: Context): Promise { // backend lands first: the surface's browser half drives the capability // the backend registers. const ids: string[] = [] - for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { - ids.push(await ctx.loader.create({ name })) - } - return async () => { - for (const id of ids.reverse()) { + const unmount = async () => { + for (const id of [...ids].reverse()) { // Tree teardown (group.stop) can have removed the entry already; // nothing is left to unmount or await then. if (ctx.loader.store[id] === undefined) continue @@ -85,5 +82,17 @@ export async function apply(ctx: Context): Promise { await ctx.loader.remove(id) } } + try { + for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { + ids.push(await ctx.loader.create({ name })) + } + } catch (cause) { + // Setup owns the entries it created until it returns the disposer: leaving + // the backend mounted would make a retry collide with its own + // directoryPicker registration. + await unmount() + throw cause + } + return unmount }, 'directory-picker-auto: interaction entries') } diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index dc39c2ae77..4e19936300 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -88,7 +88,10 @@ afterEach(async () => { }) /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ -async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> { +async function loadComposition( + bindHost: '127.0.0.1' | '0.0.0.0', + options: { failSurface?: boolean } = {}, +): Promise<{ ctx: Context; configPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -115,6 +118,9 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx context.loader.internal = { version: 'v2', async import(specifier: string) { + if (options.failSurface === true && (specifier === NATIVE_SURFACE || specifier === BROWSE_SURFACE)) { + throw new Error(`surface import failed: ${specifier}`) + } if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) return modules.get(specifier) }, @@ -209,6 +215,17 @@ describe('real Loader composition', () => { expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) }) + it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => { + stubAttendedHost() + await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/) + + // Setup owns both entries until it returns its disposer, so a failed surface + // must take the mounted backend with it: otherwise a retry collides with the + // directoryPicker registration this backend already made. + expect(entryNames(context!)).not.toContain(NATIVE) + expect(context!.get('directoryPicker')).toBeUndefined() + }) + it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => { stubAttendedHost() const { ctx, configPath } = await loadComposition('127.0.0.1') From b0d3686f57d71bf53d758f75d6b39e232eba57a8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:30:28 +0800 Subject: [PATCH 39/46] fix: pkg --- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-directory-picker/package.json | 2 +- packages/interaction/commands/package.json | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index f7a0a80bdf..f7548b3944 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-directory-picker/package.json b/packages/client/ui-directory-picker/package.json index fd3acf592d..1698d9af7e 100644 --- a/packages/client/ui-directory-picker/package.json +++ b/packages/client/ui-directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 7b9ade9d26..b654451b37 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -50,8 +50,7 @@ "lib/typert.host.d.ts", "lib/typert.remote-client.js", "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts.map" ], "license": "BSD-3-Clause", "peerDependencies": { From 7c3bd75bcca7df8750a50de28f920bf06c989a0e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:56:39 +0800 Subject: [PATCH 40/46] test(remotes): unwrap the Remote envelope in the built-lib chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated method resolves to RemoteResult, so the plain-Node script must read the business value through `.value`: the CAS ref it passed on to goals/edit was undefined, which the client codec rejected before the request left. The invalid-payload case keeps its try/catch — a codec-rejected argument still throws at the Client Remote face rather than folding into the error branch. --- packages/api/remotes/tests/built-lib.e2e.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index be76cd148c..80d194ee0e 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -147,19 +147,21 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { } catch { invalidRejected = true } + // Every generated method resolves to the RemoteResult envelope; the + // business values below are what the assertions pin. const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' }) const rootEdit = await client.remote.goals.edit( rootAgent.id, - rootResult.ref, + rootResult.value.ref, { objective: 'edited root goal' }, ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, - rootResult, - rootEdit, - scopedResult, + rootResult: rootResult.value, + rootEdit: rootEdit.value, + scopedResult: scopedResult.value, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, rootEvents: rootAgent.session.events.length, From 7ad54e7791f3bd091c3d5423dd2c912fb735c4f0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:40:03 +0800 Subject: [PATCH 41/46] refactor(client): name the compile face in every client test filename A test file under packages/client now says which face it covers: `*.client.spec.{ts,tsx}` and its `*.client.{ts,tsx}` helpers belong to the Client aggregate, `*.host.spec.ts` to the host aggregate. The carrier's four node-half specs take the Host suffix. The two suffixes are mutually exclusive, so each aggregate excludes the other's and both keep one broad test glob: `exclude` wins over `include`, and `packages/client/**` no longer has to be excluded wholesale from the host program with per-file `files` entries carved back out of it. A Host-face spec that reaches only Host source therefore needs no cross-face project reference, which the split-project rule rejects. vitest still discovers every file through `**/*.spec.{ts,tsx}`. --- ...i-helpers.spec.ts => api-helpers.client.spec.ts} | 0 ...trust.spec.ts => api-request-trust.host.spec.ts} | 0 ...nt-apply.spec.ts => client-apply.client.spec.ts} | 0 ...connection.spec.ts => connection.client.spec.ts} | 2 +- .../tests/{fake-api.ts => fake-api.client.ts} | 0 ...ands.spec.ts => fixture-commands.client.spec.ts} | 0 .../{fixture.spec.ts => fixture.client.spec.ts} | 0 ...http-bridge.spec.ts => http-bridge.host.spec.ts} | 0 ...ame.spec.ts => loopback-hostname.client.spec.ts} | 0 .../{node-half.spec.ts => node-half.host.spec.ts} | 0 ...link.spec.ts => websocket-downlink.host.spec.ts} | 0 .../{node-half.spec.ts => node-half.client.spec.ts} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 .../tests/{host.spec.ts => host.client.spec.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...ge-row.spec.tsx => language-row.client.spec.tsx} | 0 .../tests/{locale.spec.ts => locale.client.spec.ts} | 0 ...-store.spec.ts => settings-store.client.spec.ts} | 0 .../tests/{loader.spec.ts => loader.client.spec.ts} | 0 .../{node-half.spec.ts => node-half.client.spec.ts} | 0 ...nt-apply.spec.ts => client-apply.client.spec.ts} | 2 +- ...ce.spec.ts => context-provenance.client.spec.ts} | Bin ...pec.ts => conversation-assembler.client.spec.ts} | 0 ...spec.ts => conversation-registry.client.spec.ts} | 2 +- ...ersation.spec.ts => conversation.client.spec.ts} | 0 .../{event-script.ts => event-script.client.ts} | 0 .../tests/{fake-api.ts => fake-api.client.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 .../{lineage.spec.ts => lineage.client.spec.ts} | 0 .../{manager.spec.ts => manager.client.spec.ts} | 4 ++-- .../{node-half.spec.ts => node-half.client.spec.ts} | 0 .../{notifier.spec.ts => notifier.client.spec.ts} | 0 .../{partial.spec.ts => partial.client.spec.ts} | 0 ...tore.spec.ts => projection-store.client.spec.ts} | 4 ++-- ...eue-store.spec.ts => queue-store.client.spec.ts} | 2 +- .../tests/{scope.spec.ts => scope.client.spec.ts} | 0 .../{session.spec.ts => session.client.spec.ts} | 4 ++-- ...vice.spec.ts => sessions-service.client.spec.ts} | 2 +- ...service.spec.ts => slots-service.client.spec.ts} | 0 .../tests/{store.spec.ts => store.client.spec.ts} | 0 ...eage.spec.ts => subagent-lineage.client.spec.ts} | 0 .../{time-zone.spec.ts => time-zone.client.spec.ts} | 0 ...l-tree.spec.ts => tool-call-tree.client.spec.ts} | 0 ...re-events.spec.ts => wire-events.client.spec.ts} | 2 +- ...ce.spec.ts => workspaces-service.client.spec.ts} | 2 +- .../{invariant.spec.ts => invariant.client.spec.ts} | 0 .../tests/{model.spec.ts => model.client.spec.ts} | 0 ...e.spec.tsx.snap => runtime.client.spec.tsx.snap} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 .../tests/{remote.spec.ts => remote.client.spec.ts} | 0 .../{runtime.spec.tsx => runtime.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 ...mponents.spec.tsx => components.client.spec.tsx} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 .../{locales.spec.ts => locales.client.spec.ts} | 0 ...n-store.spec.ts => section-store.client.spec.ts} | 0 .../{section.spec.tsx => section.client.spec.tsx} | 0 ...-store.spec.ts => settings-store.client.spec.ts} | 0 ...ail.spec.tsx => attachment-rail.client.spec.tsx} | 0 ...tbox.spec.tsx => image-lightbox.client.spec.tsx} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...image.spec.tsx => message-image.client.spec.tsx} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 .../{directory.spec.ts => directory.client.spec.ts} | 0 ...pup-view.spec.tsx => popup-view.client.spec.tsx} | 0 .../tests/{popup.spec.ts => popup.client.spec.ts} | 0 .../{service.spec.ts => service.client.spec.ts} | 0 ...inject.spec.tsx => apply-inject.client.spec.tsx} | 0 ...s.spec.tsx => assembly-surfaces.client.spec.tsx} | 0 ...at-apply.spec.tsx => chat-apply.client.spec.tsx} | 0 ...s.spec.tsx => chat-branch-tails.client.spec.tsx} | 2 +- ...t-fixture.ts => chat-snapshot-fixture.client.ts} | 0 ...at-stats.spec.tsx => chat-stats.client.spec.tsx} | 2 +- ...chat-store.spec.ts => chat-store.client.spec.ts} | 0 ...chat-view.spec.tsx => chat-view.client.spec.tsx} | 2 +- ...meter.spec.tsx => context-meter.client.spec.tsx} | 0 ...=> conversation-node-definitions.client.spec.ts} | 0 ...ails.spec.tsx => coverage-tails.client.spec.tsx} | 0 ....spec.tsx => enter-behavior-row.client.spec.tsx} | 0 ...s.spec.tsx => gate-branch-tails.client.spec.tsx} | 2 +- .../tests/{host.spec.ts => host.client.spec.ts} | 0 ...labels.spec.tsx => image-labels.client.spec.tsx} | 0 ...input-bar.spec.tsx => input-bar.client.spec.tsx} | 0 ...machine.spec.ts => input-machine.client.spec.ts} | 0 ...matrix.spec.tsx => input-matrix.client.spec.tsx} | 0 ...ios.spec.tsx => input-scenarios.client.spec.tsx} | 2 +- ...eue-dock.spec.tsx => queue-dock.client.spec.tsx} | 0 ...g-row.spec.tsx => reasoning-row.client.spec.tsx} | 0 ....spec.tsx => selection-survival.client.spec.tsx} | 0 ...spec.ts => service-orchestration.client.spec.ts} | 0 .../{skeleton.spec.tsx => skeleton.client.spec.tsx} | 0 ...icy.spec.ts => submission-policy.client.spec.ts} | 0 ...do-panel.spec.tsx => todo-panel.client.spec.tsx} | 0 ...-metrics.spec.ts => turn-metrics.client.spec.ts} | 0 ...in.spec.tsx => views-type-chain.client.spec.tsx} | 0 ...iles.spec.tsx => produced-files.client.spec.tsx} | 0 ...nt-flow.spec.tsx => client-flow.client.spec.tsx} | 0 ...nt-flow.spec.tsx => client-flow.client.spec.tsx} | 0 ...r.spec.tsx => directory-browser.client.spec.tsx} | 0 ...ugin.spec.tsx => browser-plugin.client.spec.tsx} | 0 ....spec.tsx => goal-command-input.client.spec.tsx} | 0 .../{goalbar.spec.tsx => goalbar.client.spec.tsx} | 0 ...app-frame.spec.tsx => app-frame.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 .../{columns.spec.ts => columns.client.spec.ts} | 0 ...ut-store.spec.ts => layout-store.client.spec.ts} | 0 .../{service.spec.ts => service.client.spec.ts} | 0 ...enter.spec.ts => theme-presenter.client.spec.ts} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...select.spec.tsx => model-select.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 ...mponents.spec.tsx => components.client.spec.tsx} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...g.spec.tsx => onboarding-dialog.client.spec.tsx} | 0 ...-form.spec.tsx => provider-form.client.spec.tsx} | 0 .../{readiness.spec.ts => readiness.client.spec.ts} | 0 .../tests/{store.spec.ts => store.client.spec.ts} | 0 .../tests/{styles.spec.ts => styles.client.spec.ts} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...-row.spec.tsx => permission-row.client.spec.tsx} | 0 ...-store.spec.ts => settings-store.client.spec.ts} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...l.spec.tsx => plan-mode-control.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 .../{fields.spec.tsx => fields.client.spec.tsx} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 .../{section.spec.tsx => section.client.spec.tsx} | 0 .../tests/{stores.spec.ts => stores.client.spec.ts} | 0 .../tests/{ansi.spec.ts => ansi.client.spec.ts} | 0 .../tests/{atoms.spec.tsx => atoms.client.spec.tsx} | 0 ...de-block.spec.tsx => code-block.client.spec.tsx} | 0 ...ff-block.spec.tsx => diff-block.client.spec.tsx} | 0 ...ver-card.spec.tsx => hover-card.client.spec.tsx} | 0 .../tests/{icons.spec.tsx => icons.client.spec.tsx} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...json-tree.spec.tsx => json-tree.client.spec.tsx} | 0 ...spec.tsx => markdown-dom-parity.client.spec.tsx} | 0 ...pec.tsx => markdown-incremental.client.spec.tsx} | 0 ...t.spec.ts => markdown-plain-text.client.spec.ts} | 0 ...ec.tsx => markdown-render-units.client.spec.tsx} | 0 .../{markdown.spec.tsx => markdown.client.spec.tsx} | 0 ....spec.tsx => onboarding-surface.client.spec.tsx} | 0 ...ad-block.spec.tsx => read-block.client.spec.tsx} | 0 ...-block.spec.tsx => search-block.client.spec.tsx} | 0 ...state-dot.spec.tsx => state-dot.client.spec.tsx} | 0 ...lock.spec.tsx => terminal-block.client.spec.tsx} | 0 .../tests/{toast.spec.tsx => toast.client.spec.tsx} | 0 .../{tooltip.spec.tsx => tooltip.client.spec.tsx} | 0 ...web-block.spec.tsx => web-block.client.spec.tsx} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...de-plugin.spec.ts => node-plugin.client.spec.ts} | 0 ...l.spec.tsx => plan-review-panel.client.spec.tsx} | 0 ...r.spec.tsx => question-composer.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 ...mponents.spec.tsx => components.client.spec.tsx} | 0 .../tests/{host.spec.ts => host.client.spec.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...ec.ts => settings-document-store.client.spec.ts} | 0 ...-root.spec.tsx => settings-root.client.spec.tsx} | 0 .../tests/{shell.spec.ts => shell.client.spec.ts} | 0 ...tice.spec.tsx => welcome-notice.client.spec.tsx} | 0 ...e-store.spec.ts => welcome-store.client.spec.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 .../tests/{plugin.spec.ts => plugin.client.spec.ts} | 0 ...-scope.spec.ts => settings-scope.client.spec.ts} | 0 ...x.snap => sidebar-snapshot.client.spec.tsx.snap} | 0 .../tests/{apply.spec.tsx => apply.client.spec.tsx} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ....spec.tsx => pointer-scrollbars.client.spec.tsx} | 0 ...pec.ts => scrollbar-quiet-styles.client.spec.ts} | 0 ...r-root.spec.tsx => sidebar-root.client.spec.tsx} | 0 ...ot.spec.tsx => sidebar-snapshot.client.spec.tsx} | 0 ...styles.spec.ts => sidebar-styles.client.spec.ts} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...skill-row.spec.tsx => skill-row.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 ...re-detect.spec.ts => core-detect.client.spec.ts} | 0 .../{core-menu.spec.ts => core-menu.client.spec.ts} | 0 ...menu-view.spec.tsx => menu-view.client.spec.tsx} | 0 .../{service.spec.ts => service.client.spec.ts} | 0 .../tests/{core.spec.ts => core.client.spec.ts} | 0 ...mic-keys.spec.ts => dynamic-keys.client.spec.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...pe-chain.spec.tsx => type-chain.client.spec.tsx} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...-ui.spec.tsx => conversation-ui.client.spec.tsx} | 0 ...plugin.spec.ts => browser-plugin.client.spec.ts} | 0 ...on.spec.tsx => task-list-action.client.spec.tsx} | 0 ...-row.spec.tsx => appearance-row.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 ...boot-theme.spec.ts => boot-theme.client.spec.ts} | 0 .../tests/{host.spec.ts => host.client.spec.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...yles.spec.ts => scrollbar-styles.client.spec.ts} | 0 ...-store.spec.ts => settings-store.client.spec.ts} | 0 .../tests/{theme.spec.ts => theme.client.spec.ts} | 0 ...ow.spec.tsx => ask-question-row.client.spec.tsx} | 0 ...s.spec.tsx => assembly-surfaces.client.spec.tsx} | 2 +- ....spec.tsx => chat-code-subcalls.client.spec.tsx} | 2 +- ...ails.spec.tsx => coverage-tails.client.spec.tsx} | 0 ...diff-card.spec.tsx => diff-card.client.spec.tsx} | 2 +- ...read-card.spec.tsx => read-card.client.spec.tsx} | 2 +- ...ch-card.spec.tsx => search-card.client.spec.tsx} | 2 +- ...-card.spec.tsx => terminal-card.client.spec.tsx} | 2 +- .../{todo-row.spec.tsx => todo-row.client.spec.tsx} | 0 ...tree.spec.tsx => tool-call-tree.client.spec.tsx} | 0 ...ls-render.tsx => tool-details-render.client.tsx} | 0 ...tyles.spec.ts => tool-row-styles.client.spec.ts} | 0 .../{tool-row.spec.tsx => tool-row.client.spec.tsx} | 0 ...-slot.spec.tsx => toolview-slot.client.spec.tsx} | 2 +- ...spec.tsx => toolview-type-chain.client.spec.tsx} | 0 .../{web-card.spec.tsx => web-card.client.spec.tsx} | 2 +- .../tests/{cell.spec.tsx => cell.client.spec.tsx} | 0 ...-bundle.spec.ts => client-bundle.client.spec.ts} | 0 ...c.ts => conversation-definitions.client.spec.ts} | 0 ...export-log.spec.ts => export-log.client.spec.ts} | 0 .../{layout.spec.tsx => layout.client.spec.tsx} | 0 ...lder.spec.ts => snapshot-builder.client.spec.ts} | 0 .../tests/{table.spec.tsx => table.client.spec.tsx} | 0 .../{toolbar.spec.tsx => toolbar.client.spec.tsx} | 0 .../tests/{views.spec.tsx => views.client.spec.tsx} | 0 ...ual-rows.spec.ts => virtual-rows.client.spec.ts} | 0 ...ow-run.spec.tsx => workflow-run.client.spec.tsx} | 0 .../tests/{apply.spec.ts => apply.client.spec.ts} | 0 ...styles.spec.ts => browser-styles.client.spec.ts} | 0 .../{invariant.spec.ts => invariant.client.spec.ts} | 0 ...bly.spec.tsx => rename-assembly.client.spec.tsx} | 0 .../tests/{rows.spec.tsx => rows.client.spec.tsx} | 0 .../tests/{tree.spec.ts => tree.client.spec.ts} | 0 ...r.spec.tsx => workspace-browser.client.spec.tsx} | 0 ...er.spec.tsx => workspace-picker.client.spec.tsx} | 0 .../tests/{bind.spec.tsx => bind.client.spec.tsx} | 0 ...c.tsx => scoped-slots-real-core.client.spec.tsx} | 0 ...-slots.spec.tsx => scoped-slots.client.spec.tsx} | 0 ...er.spec.tsx => session-provider.client.spec.tsx} | 0 ...spec.tsx => stale-authorization.client.spec.tsx} | 0 ...e-invoke.spec.tsx => use-invoke.client.spec.tsx} | 0 ...tion.spec.tsx => use-projection.client.spec.tsx} | 0 .../{app-root.spec.tsx => app-root.client.spec.tsx} | 0 ...app-shell.spec.tsx => app-shell.client.spec.tsx} | 0 .../web/tests/{app.spec.tsx => app.client.spec.tsx} | 0 ...se-styles.spec.ts => base-styles.client.spec.ts} | 0 ...itle.spec.tsx => document-title.client.spec.tsx} | 0 scripts/rescope-vendor.ts | 10 +++++----- tsconfig.client.json | 9 ++++++--- tsconfig.host.json | 11 ++++++++++- 246 files changed, 47 insertions(+), 35 deletions(-) rename packages/client/connection/tests/{api-helpers.spec.ts => api-helpers.client.spec.ts} (100%) rename packages/client/connection/tests/{api-request-trust.spec.ts => api-request-trust.host.spec.ts} (100%) rename packages/client/connection/tests/{client-apply.spec.ts => client-apply.client.spec.ts} (100%) rename packages/client/connection/tests/{connection.spec.ts => connection.client.spec.ts} (99%) rename packages/client/connection/tests/{fake-api.ts => fake-api.client.ts} (100%) rename packages/client/connection/tests/{fixture-commands.spec.ts => fixture-commands.client.spec.ts} (100%) rename packages/client/connection/tests/{fixture.spec.ts => fixture.client.spec.ts} (100%) rename packages/client/connection/tests/{http-bridge.spec.ts => http-bridge.host.spec.ts} (100%) rename packages/client/connection/tests/{loopback-hostname.spec.ts => loopback-hostname.client.spec.ts} (100%) rename packages/client/connection/tests/{node-half.spec.ts => node-half.host.spec.ts} (100%) rename packages/client/connection/tests/{websocket-downlink.spec.ts => websocket-downlink.host.spec.ts} (100%) rename packages/client/hmr/tests/{node-half.spec.ts => node-half.client.spec.ts} (100%) rename packages/client/locale/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/locale/tests/{host.spec.ts => host.client.spec.ts} (100%) rename packages/client/locale/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/locale/tests/{language-row.spec.tsx => language-row.client.spec.tsx} (100%) rename packages/client/locale/tests/{locale.spec.ts => locale.client.spec.ts} (100%) rename packages/client/locale/tests/{settings-store.spec.ts => settings-store.client.spec.ts} (100%) rename packages/client/modules/tests/{loader.spec.ts => loader.client.spec.ts} (100%) rename packages/client/modules/tests/{node-half.spec.ts => node-half.client.spec.ts} (100%) rename packages/client/runtime/tests/{client-apply.spec.ts => client-apply.client.spec.ts} (98%) rename packages/client/runtime/tests/{context-provenance.spec.ts => context-provenance.client.spec.ts} (100%) rename packages/client/runtime/tests/{conversation-assembler.spec.ts => conversation-assembler.client.spec.ts} (100%) rename packages/client/runtime/tests/{conversation-registry.spec.ts => conversation-registry.client.spec.ts} (98%) rename packages/client/runtime/tests/{conversation.spec.ts => conversation.client.spec.ts} (100%) rename packages/client/runtime/tests/{event-script.ts => event-script.client.ts} (100%) rename packages/client/runtime/tests/{fake-api.ts => fake-api.client.ts} (100%) rename packages/client/runtime/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/runtime/tests/{lineage.spec.ts => lineage.client.spec.ts} (100%) rename packages/client/runtime/tests/{manager.spec.ts => manager.client.spec.ts} (99%) rename packages/client/runtime/tests/{node-half.spec.ts => node-half.client.spec.ts} (100%) rename packages/client/runtime/tests/{notifier.spec.ts => notifier.client.spec.ts} (100%) rename packages/client/runtime/tests/{partial.spec.ts => partial.client.spec.ts} (100%) rename packages/client/runtime/tests/{projection-store.spec.ts => projection-store.client.spec.ts} (98%) rename packages/client/runtime/tests/{queue-store.spec.ts => queue-store.client.spec.ts} (99%) rename packages/client/runtime/tests/{scope.spec.ts => scope.client.spec.ts} (100%) rename packages/client/runtime/tests/{session.spec.ts => session.client.spec.ts} (99%) rename packages/client/runtime/tests/{sessions-service.spec.ts => sessions-service.client.spec.ts} (99%) rename packages/client/runtime/tests/{slots-service.spec.ts => slots-service.client.spec.ts} (100%) rename packages/client/runtime/tests/{store.spec.ts => store.client.spec.ts} (100%) rename packages/client/runtime/tests/{subagent-lineage.spec.ts => subagent-lineage.client.spec.ts} (100%) rename packages/client/runtime/tests/{time-zone.spec.ts => time-zone.client.spec.ts} (100%) rename packages/client/runtime/tests/{tool-call-tree.spec.ts => tool-call-tree.client.spec.ts} (100%) rename packages/client/runtime/tests/{wire-events.spec.ts => wire-events.client.spec.ts} (98%) rename packages/client/runtime/tests/{workspaces-service.spec.ts => workspaces-service.client.spec.ts} (99%) rename packages/client/schema-form/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/schema-form/tests/{model.spec.ts => model.client.spec.ts} (100%) rename packages/client/test-runtime/tests/__snapshots__/{runtime.spec.tsx.snap => runtime.client.spec.tsx.snap} (100%) rename packages/client/test-runtime/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/test-runtime/tests/{remote.spec.ts => remote.client.spec.ts} (100%) rename packages/client/test-runtime/tests/{runtime.spec.tsx => runtime.client.spec.tsx} (100%) rename packages/client/ui-agent-preset/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-agent-preset/tests/{components.spec.tsx => components.client.spec.tsx} (100%) rename packages/client/ui-agent-preset/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-agent-preset/tests/{locales.spec.ts => locales.client.spec.ts} (100%) rename packages/client/ui-agent-preset/tests/{section-store.spec.ts => section-store.client.spec.ts} (100%) rename packages/client/ui-agent-preset/tests/{section.spec.tsx => section.client.spec.tsx} (100%) rename packages/client/ui-agent-preset/tests/{settings-store.spec.ts => settings-store.client.spec.ts} (100%) rename packages/client/ui-attachment/tests/{attachment-rail.spec.tsx => attachment-rail.client.spec.tsx} (100%) rename packages/client/ui-attachment/tests/{image-lightbox.spec.tsx => image-lightbox.client.spec.tsx} (100%) rename packages/client/ui-attachment/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-attachment/tests/{message-image.spec.tsx => message-image.client.spec.tsx} (100%) rename packages/client/ui-command/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-command/tests/{directory.spec.ts => directory.client.spec.ts} (100%) rename packages/client/ui-command/tests/{popup-view.spec.tsx => popup-view.client.spec.tsx} (100%) rename packages/client/ui-command/tests/{popup.spec.ts => popup.client.spec.ts} (100%) rename packages/client/ui-command/tests/{service.spec.ts => service.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{apply-inject.spec.tsx => apply-inject.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{assembly-surfaces.spec.tsx => assembly-surfaces.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{chat-apply.spec.tsx => chat-apply.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{chat-branch-tails.spec.tsx => chat-branch-tails.client.spec.tsx} (99%) rename packages/client/ui-conversation/tests/{chat-snapshot-fixture.ts => chat-snapshot-fixture.client.ts} (100%) rename packages/client/ui-conversation/tests/{chat-stats.spec.tsx => chat-stats.client.spec.tsx} (99%) rename packages/client/ui-conversation/tests/{chat-store.spec.ts => chat-store.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{chat-view.spec.tsx => chat-view.client.spec.tsx} (99%) rename packages/client/ui-conversation/tests/{context-meter.spec.tsx => context-meter.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{conversation-node-definitions.spec.ts => conversation-node-definitions.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{coverage-tails.spec.tsx => coverage-tails.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{enter-behavior-row.spec.tsx => enter-behavior-row.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{gate-branch-tails.spec.tsx => gate-branch-tails.client.spec.tsx} (99%) rename packages/client/ui-conversation/tests/{host.spec.ts => host.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{image-labels.spec.tsx => image-labels.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{input-bar.spec.tsx => input-bar.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{input-machine.spec.ts => input-machine.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{input-matrix.spec.tsx => input-matrix.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{input-scenarios.spec.tsx => input-scenarios.client.spec.tsx} (99%) rename packages/client/ui-conversation/tests/{queue-dock.spec.tsx => queue-dock.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{reasoning-row.spec.tsx => reasoning-row.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{selection-survival.spec.tsx => selection-survival.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{service-orchestration.spec.ts => service-orchestration.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{skeleton.spec.tsx => skeleton.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{submission-policy.spec.ts => submission-policy.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{todo-panel.spec.tsx => todo-panel.client.spec.tsx} (100%) rename packages/client/ui-conversation/tests/{turn-metrics.spec.ts => turn-metrics.client.spec.ts} (100%) rename packages/client/ui-conversation/tests/{views-type-chain.spec.tsx => views-type-chain.client.spec.tsx} (100%) rename packages/client/ui-deliverables/tests/{produced-files.spec.tsx => produced-files.client.spec.tsx} (100%) rename packages/client/ui-directory-picker-native/tests/{client-flow.spec.tsx => client-flow.client.spec.tsx} (100%) rename packages/client/ui-directory-picker/tests/{client-flow.spec.tsx => client-flow.client.spec.tsx} (100%) rename packages/client/ui-directory-picker/tests/{directory-browser.spec.tsx => directory-browser.client.spec.tsx} (100%) rename packages/client/ui-goal/tests/{browser-plugin.spec.tsx => browser-plugin.client.spec.tsx} (100%) rename packages/client/ui-goal/tests/{goal-command-input.spec.tsx => goal-command-input.client.spec.tsx} (100%) rename packages/client/ui-goal/tests/{goalbar.spec.tsx => goalbar.client.spec.tsx} (100%) rename packages/client/ui-layout/tests/{app-frame.spec.tsx => app-frame.client.spec.tsx} (100%) rename packages/client/ui-layout/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-layout/tests/{columns.spec.ts => columns.client.spec.ts} (100%) rename packages/client/ui-layout/tests/{layout-store.spec.ts => layout-store.client.spec.ts} (100%) rename packages/client/ui-layout/tests/{service.spec.ts => service.client.spec.ts} (100%) rename packages/client/ui-layout/tests/{theme-presenter.spec.ts => theme-presenter.client.spec.ts} (100%) rename packages/client/ui-model/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-model/tests/{model-select.spec.tsx => model-select.client.spec.tsx} (100%) rename packages/client/ui-models/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-models/tests/{components.spec.tsx => components.client.spec.tsx} (100%) rename packages/client/ui-models/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-models/tests/{onboarding-dialog.spec.tsx => onboarding-dialog.client.spec.tsx} (100%) rename packages/client/ui-models/tests/{provider-form.spec.tsx => provider-form.client.spec.tsx} (100%) rename packages/client/ui-models/tests/{readiness.spec.ts => readiness.client.spec.ts} (100%) rename packages/client/ui-models/tests/{store.spec.ts => store.client.spec.ts} (100%) rename packages/client/ui-models/tests/{styles.spec.ts => styles.client.spec.ts} (100%) rename packages/client/ui-permission/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-permission/tests/{permission-row.spec.tsx => permission-row.client.spec.tsx} (100%) rename packages/client/ui-permission/tests/{settings-store.spec.ts => settings-store.client.spec.ts} (100%) rename packages/client/ui-plan/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-plan/tests/{plan-mode-control.spec.tsx => plan-mode-control.client.spec.tsx} (100%) rename packages/client/ui-plugin-config/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-plugin-config/tests/{fields.spec.tsx => fields.client.spec.tsx} (100%) rename packages/client/ui-plugin-config/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-plugin-config/tests/{section.spec.tsx => section.client.spec.tsx} (100%) rename packages/client/ui-plugin-config/tests/{stores.spec.ts => stores.client.spec.ts} (100%) rename packages/client/ui-primitives/tests/{ansi.spec.ts => ansi.client.spec.ts} (100%) rename packages/client/ui-primitives/tests/{atoms.spec.tsx => atoms.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{code-block.spec.tsx => code-block.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{diff-block.spec.tsx => diff-block.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{hover-card.spec.tsx => hover-card.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{icons.spec.tsx => icons.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-primitives/tests/{json-tree.spec.tsx => json-tree.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{markdown-dom-parity.spec.tsx => markdown-dom-parity.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{markdown-incremental.spec.tsx => markdown-incremental.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{markdown-plain-text.spec.ts => markdown-plain-text.client.spec.ts} (100%) rename packages/client/ui-primitives/tests/{markdown-render-units.spec.tsx => markdown-render-units.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{markdown.spec.tsx => markdown.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{onboarding-surface.spec.tsx => onboarding-surface.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{read-block.spec.tsx => read-block.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{search-block.spec.tsx => search-block.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{state-dot.spec.tsx => state-dot.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{terminal-block.spec.tsx => terminal-block.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{toast.spec.tsx => toast.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{tooltip.spec.tsx => tooltip.client.spec.tsx} (100%) rename packages/client/ui-primitives/tests/{web-block.spec.tsx => web-block.client.spec.tsx} (100%) rename packages/client/ui-question/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-question/tests/{node-plugin.spec.ts => node-plugin.client.spec.ts} (100%) rename packages/client/ui-question/tests/{plan-review-panel.spec.tsx => plan-review-panel.client.spec.tsx} (100%) rename packages/client/ui-question/tests/{question-composer.spec.tsx => question-composer.client.spec.tsx} (100%) rename packages/client/ui-settings-general/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-settings-general/tests/{components.spec.tsx => components.client.spec.tsx} (100%) rename packages/client/ui-settings-general/tests/{host.spec.ts => host.client.spec.ts} (100%) rename packages/client/ui-settings-general/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-settings-general/tests/{settings-document-store.spec.ts => settings-document-store.client.spec.ts} (100%) rename packages/client/ui-settings-general/tests/{settings-root.spec.tsx => settings-root.client.spec.tsx} (100%) rename packages/client/ui-settings-general/tests/{shell.spec.ts => shell.client.spec.ts} (100%) rename packages/client/ui-settings-general/tests/{welcome-notice.spec.tsx => welcome-notice.client.spec.tsx} (100%) rename packages/client/ui-settings-general/tests/{welcome-store.spec.ts => welcome-store.client.spec.ts} (100%) rename packages/client/ui-settings/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-settings/tests/{plugin.spec.ts => plugin.client.spec.ts} (100%) rename packages/client/ui-settings/tests/{settings-scope.spec.ts => settings-scope.client.spec.ts} (100%) rename packages/client/ui-sidebar/tests/__snapshots__/{sidebar-snapshot.spec.tsx.snap => sidebar-snapshot.client.spec.tsx.snap} (100%) rename packages/client/ui-sidebar/tests/{apply.spec.tsx => apply.client.spec.tsx} (100%) rename packages/client/ui-sidebar/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-sidebar/tests/{pointer-scrollbars.spec.tsx => pointer-scrollbars.client.spec.tsx} (100%) rename packages/client/ui-sidebar/tests/{scrollbar-quiet-styles.spec.ts => scrollbar-quiet-styles.client.spec.ts} (100%) rename packages/client/ui-sidebar/tests/{sidebar-root.spec.tsx => sidebar-root.client.spec.tsx} (100%) rename packages/client/ui-sidebar/tests/{sidebar-snapshot.spec.tsx => sidebar-snapshot.client.spec.tsx} (100%) rename packages/client/ui-sidebar/tests/{sidebar-styles.spec.ts => sidebar-styles.client.spec.ts} (100%) rename packages/client/ui-skill/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-skill/tests/{skill-row.spec.tsx => skill-row.client.spec.tsx} (100%) rename packages/client/ui-slash/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-slash/tests/{core-detect.spec.ts => core-detect.client.spec.ts} (100%) rename packages/client/ui-slash/tests/{core-menu.spec.ts => core-menu.client.spec.ts} (100%) rename packages/client/ui-slash/tests/{menu-view.spec.tsx => menu-view.client.spec.tsx} (100%) rename packages/client/ui-slash/tests/{service.spec.ts => service.client.spec.ts} (100%) rename packages/client/ui-slots/tests/{core.spec.ts => core.client.spec.ts} (100%) rename packages/client/ui-slots/tests/{dynamic-keys.spec.ts => dynamic-keys.client.spec.ts} (100%) rename packages/client/ui-slots/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-slots/tests/{type-chain.spec.tsx => type-chain.client.spec.tsx} (100%) rename packages/client/ui-subagent/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-subagent/tests/{conversation-ui.spec.tsx => conversation-ui.client.spec.tsx} (100%) rename packages/client/ui-task/tests/{browser-plugin.spec.ts => browser-plugin.client.spec.ts} (100%) rename packages/client/ui-task/tests/{task-list-action.spec.tsx => task-list-action.client.spec.tsx} (100%) rename packages/client/ui-theme/tests/{appearance-row.spec.tsx => appearance-row.client.spec.tsx} (100%) rename packages/client/ui-theme/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-theme/tests/{boot-theme.spec.ts => boot-theme.client.spec.ts} (100%) rename packages/client/ui-theme/tests/{host.spec.ts => host.client.spec.ts} (100%) rename packages/client/ui-theme/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-theme/tests/{scrollbar-styles.spec.ts => scrollbar-styles.client.spec.ts} (100%) rename packages/client/ui-theme/tests/{settings-store.spec.ts => settings-store.client.spec.ts} (100%) rename packages/client/ui-theme/tests/{theme.spec.ts => theme.client.spec.ts} (100%) rename packages/client/ui-tool/tests/{ask-question-row.spec.tsx => ask-question-row.client.spec.tsx} (100%) rename packages/client/ui-tool/tests/{assembly-surfaces.spec.tsx => assembly-surfaces.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{chat-code-subcalls.spec.tsx => chat-code-subcalls.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{coverage-tails.spec.tsx => coverage-tails.client.spec.tsx} (100%) rename packages/client/ui-tool/tests/{diff-card.spec.tsx => diff-card.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{read-card.spec.tsx => read-card.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{search-card.spec.tsx => search-card.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{terminal-card.spec.tsx => terminal-card.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{todo-row.spec.tsx => todo-row.client.spec.tsx} (100%) rename packages/client/ui-tool/tests/{tool-call-tree.spec.tsx => tool-call-tree.client.spec.tsx} (100%) rename packages/client/ui-tool/tests/{tool-details-render.tsx => tool-details-render.client.tsx} (100%) rename packages/client/ui-tool/tests/{tool-row-styles.spec.ts => tool-row-styles.client.spec.ts} (100%) rename packages/client/ui-tool/tests/{tool-row.spec.tsx => tool-row.client.spec.tsx} (100%) rename packages/client/ui-tool/tests/{toolview-slot.spec.tsx => toolview-slot.client.spec.tsx} (99%) rename packages/client/ui-tool/tests/{toolview-type-chain.spec.tsx => toolview-type-chain.client.spec.tsx} (100%) rename packages/client/ui-tool/tests/{web-card.spec.tsx => web-card.client.spec.tsx} (99%) rename packages/client/ui-trajectory/tests/{cell.spec.tsx => cell.client.spec.tsx} (100%) rename packages/client/ui-trajectory/tests/{client-bundle.spec.ts => client-bundle.client.spec.ts} (100%) rename packages/client/ui-trajectory/tests/{conversation-definitions.spec.ts => conversation-definitions.client.spec.ts} (100%) rename packages/client/ui-trajectory/tests/{export-log.spec.ts => export-log.client.spec.ts} (100%) rename packages/client/ui-trajectory/tests/{layout.spec.tsx => layout.client.spec.tsx} (100%) rename packages/client/ui-trajectory/tests/{snapshot-builder.spec.ts => snapshot-builder.client.spec.ts} (100%) rename packages/client/ui-trajectory/tests/{table.spec.tsx => table.client.spec.tsx} (100%) rename packages/client/ui-trajectory/tests/{toolbar.spec.tsx => toolbar.client.spec.tsx} (100%) rename packages/client/ui-trajectory/tests/{views.spec.tsx => views.client.spec.tsx} (100%) rename packages/client/ui-trajectory/tests/{virtual-rows.spec.ts => virtual-rows.client.spec.ts} (100%) rename packages/client/ui-workflow-run/tests/{workflow-run.spec.tsx => workflow-run.client.spec.tsx} (100%) rename packages/client/ui-workspace/tests/{apply.spec.ts => apply.client.spec.ts} (100%) rename packages/client/ui-workspace/tests/{browser-styles.spec.ts => browser-styles.client.spec.ts} (100%) rename packages/client/ui-workspace/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) rename packages/client/ui-workspace/tests/{rename-assembly.spec.tsx => rename-assembly.client.spec.tsx} (100%) rename packages/client/ui-workspace/tests/{rows.spec.tsx => rows.client.spec.tsx} (100%) rename packages/client/ui-workspace/tests/{tree.spec.ts => tree.client.spec.ts} (100%) rename packages/client/ui-workspace/tests/{workspace-browser.spec.tsx => workspace-browser.client.spec.tsx} (100%) rename packages/client/ui-workspace/tests/{workspace-picker.spec.tsx => workspace-picker.client.spec.tsx} (100%) rename packages/client/web-react/tests/{bind.spec.tsx => bind.client.spec.tsx} (100%) rename packages/client/web-react/tests/{scoped-slots-real-core.spec.tsx => scoped-slots-real-core.client.spec.tsx} (100%) rename packages/client/web-react/tests/{scoped-slots.spec.tsx => scoped-slots.client.spec.tsx} (100%) rename packages/client/web-react/tests/{session-provider.spec.tsx => session-provider.client.spec.tsx} (100%) rename packages/client/web-react/tests/{stale-authorization.spec.tsx => stale-authorization.client.spec.tsx} (100%) rename packages/client/web-react/tests/{use-invoke.spec.tsx => use-invoke.client.spec.tsx} (100%) rename packages/client/web-react/tests/{use-projection.spec.tsx => use-projection.client.spec.tsx} (100%) rename packages/client/web/tests/{app-root.spec.tsx => app-root.client.spec.tsx} (100%) rename packages/client/web/tests/{app-shell.spec.tsx => app-shell.client.spec.tsx} (100%) rename packages/client/web/tests/{app.spec.tsx => app.client.spec.tsx} (100%) rename packages/client/web/tests/{base-styles.spec.ts => base-styles.client.spec.ts} (100%) rename packages/client/web/tests/{document-title.spec.tsx => document-title.client.spec.tsx} (100%) diff --git a/packages/client/connection/tests/api-helpers.spec.ts b/packages/client/connection/tests/api-helpers.client.spec.ts similarity index 100% rename from packages/client/connection/tests/api-helpers.spec.ts rename to packages/client/connection/tests/api-helpers.client.spec.ts diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.host.spec.ts similarity index 100% rename from packages/client/connection/tests/api-request-trust.spec.ts rename to packages/client/connection/tests/api-request-trust.host.spec.ts diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts similarity index 100% rename from packages/client/connection/tests/client-apply.spec.ts rename to packages/client/connection/tests/client-apply.client.spec.ts diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.client.spec.ts similarity index 99% rename from packages/client/connection/tests/connection.spec.ts rename to packages/client/connection/tests/connection.client.spec.ts index 3a39122365..e5290c0dbb 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '../src/client/api.ts' import type { ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' -import { FakeApiClient, deferred, ok } from './fake-api.ts' +import { FakeApiClient, deferred, ok } from './fake-api.client.ts' const SID = 'fk-c1' as SessionId const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 } diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.client.ts similarity index 100% rename from packages/client/connection/tests/fake-api.ts rename to packages/client/connection/tests/fake-api.client.ts diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts similarity index 100% rename from packages/client/connection/tests/fixture-commands.spec.ts rename to packages/client/connection/tests/fixture-commands.client.spec.ts diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts similarity index 100% rename from packages/client/connection/tests/fixture.spec.ts rename to packages/client/connection/tests/fixture.client.spec.ts diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts similarity index 100% rename from packages/client/connection/tests/http-bridge.spec.ts rename to packages/client/connection/tests/http-bridge.host.spec.ts diff --git a/packages/client/connection/tests/loopback-hostname.spec.ts b/packages/client/connection/tests/loopback-hostname.client.spec.ts similarity index 100% rename from packages/client/connection/tests/loopback-hostname.spec.ts rename to packages/client/connection/tests/loopback-hostname.client.spec.ts diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts similarity index 100% rename from packages/client/connection/tests/node-half.spec.ts rename to packages/client/connection/tests/node-half.host.spec.ts diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.host.spec.ts similarity index 100% rename from packages/client/connection/tests/websocket-downlink.spec.ts rename to packages/client/connection/tests/websocket-downlink.host.spec.ts diff --git a/packages/client/hmr/tests/node-half.spec.ts b/packages/client/hmr/tests/node-half.client.spec.ts similarity index 100% rename from packages/client/hmr/tests/node-half.spec.ts rename to packages/client/hmr/tests/node-half.client.spec.ts diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.client.spec.ts similarity index 100% rename from packages/client/locale/tests/apply.spec.ts rename to packages/client/locale/tests/apply.client.spec.ts diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.client.spec.ts similarity index 100% rename from packages/client/locale/tests/host.spec.ts rename to packages/client/locale/tests/host.client.spec.ts diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/locale/tests/invariant.spec.ts rename to packages/client/locale/tests/invariant.client.spec.ts diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx similarity index 100% rename from packages/client/locale/tests/language-row.spec.tsx rename to packages/client/locale/tests/language-row.client.spec.tsx diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.client.spec.ts similarity index 100% rename from packages/client/locale/tests/locale.spec.ts rename to packages/client/locale/tests/locale.client.spec.ts diff --git a/packages/client/locale/tests/settings-store.spec.ts b/packages/client/locale/tests/settings-store.client.spec.ts similarity index 100% rename from packages/client/locale/tests/settings-store.spec.ts rename to packages/client/locale/tests/settings-store.client.spec.ts diff --git a/packages/client/modules/tests/loader.spec.ts b/packages/client/modules/tests/loader.client.spec.ts similarity index 100% rename from packages/client/modules/tests/loader.spec.ts rename to packages/client/modules/tests/loader.client.spec.ts diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts similarity index 100% rename from packages/client/modules/tests/node-half.spec.ts rename to packages/client/modules/tests/node-half.client.spec.ts diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/client-apply.spec.ts rename to packages/client/runtime/tests/client-apply.client.spec.ts index ae1e20a45b..1d3e6d0fee 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.client.spec.ts @@ -14,7 +14,7 @@ import type { ConversationNodeDefinition } from '../src/client/contract/conversa import { Session } from '../src/client/sessions/session.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' interface Bench { ctx: Context diff --git a/packages/client/runtime/tests/context-provenance.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/context-provenance.spec.ts rename to packages/client/runtime/tests/context-provenance.client.spec.ts diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/conversation-assembler.spec.ts rename to packages/client/runtime/tests/conversation-assembler.client.spec.ts diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/conversation-registry.spec.ts rename to packages/client/runtime/tests/conversation-registry.client.spec.ts index 79d63f3226..dbd752f4a9 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.client.spec.ts @@ -8,7 +8,7 @@ import type { } from '../src/client/contract/conversation.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionsService } from '../src/client/sessions/service.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' function eventDefinition(kind: string): ConversationNodeDefinition { return { diff --git a/packages/client/runtime/tests/conversation.spec.ts b/packages/client/runtime/tests/conversation.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/conversation.spec.ts rename to packages/client/runtime/tests/conversation.client.spec.ts diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.client.ts similarity index 100% rename from packages/client/runtime/tests/event-script.ts rename to packages/client/runtime/tests/event-script.client.ts diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.client.ts similarity index 100% rename from packages/client/runtime/tests/fake-api.ts rename to packages/client/runtime/tests/fake-api.client.ts diff --git a/packages/client/runtime/tests/invariant.spec.ts b/packages/client/runtime/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/invariant.spec.ts rename to packages/client/runtime/tests/invariant.client.spec.ts diff --git a/packages/client/runtime/tests/lineage.spec.ts b/packages/client/runtime/tests/lineage.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/lineage.spec.ts rename to packages/client/runtime/tests/lineage.client.spec.ts diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/manager.spec.ts rename to packages/client/runtime/tests/manager.client.spec.ts index dec157341a..02e00b0326 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.client.spec.ts @@ -6,8 +6,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' -import { entries, plainTurn } from './event-script.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { entries, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId diff --git a/packages/client/runtime/tests/node-half.spec.ts b/packages/client/runtime/tests/node-half.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/node-half.spec.ts rename to packages/client/runtime/tests/node-half.client.spec.ts diff --git a/packages/client/runtime/tests/notifier.spec.ts b/packages/client/runtime/tests/notifier.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/notifier.spec.ts rename to packages/client/runtime/tests/notifier.client.spec.ts diff --git a/packages/client/runtime/tests/partial.spec.ts b/packages/client/runtime/tests/partial.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/partial.spec.ts rename to packages/client/runtime/tests/partial.client.spec.ts diff --git a/packages/client/runtime/tests/projection-store.spec.ts b/packages/client/runtime/tests/projection-store.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/projection-store.spec.ts rename to packages/client/runtime/tests/projection-store.client.spec.ts index 45b75e91a1..5ef2c41194 100644 --- a/packages/client/runtime/tests/projection-store.spec.ts +++ b/packages/client/runtime/tests/projection-store.client.spec.ts @@ -12,8 +12,8 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.ts' -import { entries, plainTurn } from './event-script.ts' +import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' +import { entries, plainTurn } from './event-script.client.ts' // Test-domain keys merged into the projection map (the Service Definition package's // pure-type outlet), the same way domain host plugins merge theirs. diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/queue-store.spec.ts rename to packages/client/runtime/tests/queue-store.client.spec.ts index d93b462d25..da109e7da7 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.client.spec.ts @@ -10,7 +10,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, fakeRemote } from './fake-api.ts' +import { FakeApiClient, fakeRemote } from './fake-api.client.ts' const SID = 'fk-q1' as SessionId const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] diff --git a/packages/client/runtime/tests/scope.spec.ts b/packages/client/runtime/tests/scope.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/scope.spec.ts rename to packages/client/runtime/tests/scope.client.spec.ts diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/session.spec.ts rename to packages/client/runtime/tests/session.client.spec.ts index 43a78cffb2..19e80dfd5b 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.client.spec.ts @@ -17,8 +17,8 @@ import type { ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, ConversationViewDefinition, } from '../src/client/index.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' -import { entries, ev, plainTurn } from './event-script.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { entries, ev, plainTurn } from './event-script.client.ts' const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/sessions-service.spec.ts rename to packages/client/runtime/tests/sessions-service.client.spec.ts index 956be93581..bf4710d102 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.client.spec.ts @@ -10,7 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' const sid = (s: string): SessionId => s as SessionId diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/slots-service.spec.ts rename to packages/client/runtime/tests/slots-service.client.spec.ts diff --git a/packages/client/runtime/tests/store.spec.ts b/packages/client/runtime/tests/store.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/store.spec.ts rename to packages/client/runtime/tests/store.client.spec.ts diff --git a/packages/client/runtime/tests/subagent-lineage.spec.ts b/packages/client/runtime/tests/subagent-lineage.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/subagent-lineage.spec.ts rename to packages/client/runtime/tests/subagent-lineage.client.spec.ts diff --git a/packages/client/runtime/tests/time-zone.spec.ts b/packages/client/runtime/tests/time-zone.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/time-zone.spec.ts rename to packages/client/runtime/tests/time-zone.client.spec.ts diff --git a/packages/client/runtime/tests/tool-call-tree.spec.ts b/packages/client/runtime/tests/tool-call-tree.client.spec.ts similarity index 100% rename from packages/client/runtime/tests/tool-call-tree.spec.ts rename to packages/client/runtime/tests/tool-call-tree.client.spec.ts diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/wire-events.spec.ts rename to packages/client/runtime/tests/wire-events.client.spec.ts index 20ff4c966d..f6798bd914 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.client.spec.ts @@ -13,7 +13,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' // key face and per-event listener signatures. import type {} from '@deepseek-ai/dsh-api-remotes/client' import * as RuntimeClient from '../src/client/index.ts' -import { FakeApiClient, fakeRemote } from './fake-api.ts' +import { FakeApiClient, fakeRemote } from './fake-api.client.ts' /** * Compile-time face of `ctx.remote.$on`, asserted by type-checking this file diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/workspaces-service.spec.ts rename to packages/client/runtime/tests/workspaces-service.client.spec.ts index d0adfd3a7f..02df6e60ad 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.client.spec.ts @@ -4,7 +4,7 @@ import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' const sid = (id: string): SessionId => id as SessionId const wid = (id: string): WorkspaceId => id as WorkspaceId diff --git a/packages/client/schema-form/tests/invariant.spec.ts b/packages/client/schema-form/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/schema-form/tests/invariant.spec.ts rename to packages/client/schema-form/tests/invariant.client.spec.ts diff --git a/packages/client/schema-form/tests/model.spec.ts b/packages/client/schema-form/tests/model.client.spec.ts similarity index 100% rename from packages/client/schema-form/tests/model.spec.ts rename to packages/client/schema-form/tests/model.client.spec.ts diff --git a/packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap b/packages/client/test-runtime/tests/__snapshots__/runtime.client.spec.tsx.snap similarity index 100% rename from packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap rename to packages/client/test-runtime/tests/__snapshots__/runtime.client.spec.tsx.snap diff --git a/packages/client/test-runtime/tests/invariant.spec.ts b/packages/client/test-runtime/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/test-runtime/tests/invariant.spec.ts rename to packages/client/test-runtime/tests/invariant.client.spec.ts diff --git a/packages/client/test-runtime/tests/remote.spec.ts b/packages/client/test-runtime/tests/remote.client.spec.ts similarity index 100% rename from packages/client/test-runtime/tests/remote.spec.ts rename to packages/client/test-runtime/tests/remote.client.spec.ts diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.client.spec.tsx similarity index 100% rename from packages/client/test-runtime/tests/runtime.spec.tsx rename to packages/client/test-runtime/tests/runtime.client.spec.tsx diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-agent-preset/tests/apply.spec.ts rename to packages/client/ui-agent-preset/tests/apply.client.spec.ts diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx similarity index 100% rename from packages/client/ui-agent-preset/tests/components.spec.tsx rename to packages/client/ui-agent-preset/tests/components.client.spec.tsx diff --git a/packages/client/ui-agent-preset/tests/invariant.spec.ts b/packages/client/ui-agent-preset/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-agent-preset/tests/invariant.spec.ts rename to packages/client/ui-agent-preset/tests/invariant.client.spec.ts diff --git a/packages/client/ui-agent-preset/tests/locales.spec.ts b/packages/client/ui-agent-preset/tests/locales.client.spec.ts similarity index 100% rename from packages/client/ui-agent-preset/tests/locales.spec.ts rename to packages/client/ui-agent-preset/tests/locales.client.spec.ts diff --git a/packages/client/ui-agent-preset/tests/section-store.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts similarity index 100% rename from packages/client/ui-agent-preset/tests/section-store.spec.ts rename to packages/client/ui-agent-preset/tests/section-store.client.spec.ts diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx similarity index 100% rename from packages/client/ui-agent-preset/tests/section.spec.tsx rename to packages/client/ui-agent-preset/tests/section.client.spec.tsx diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts similarity index 100% rename from packages/client/ui-agent-preset/tests/settings-store.spec.ts rename to packages/client/ui-agent-preset/tests/settings-store.client.spec.ts diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx similarity index 100% rename from packages/client/ui-attachment/tests/attachment-rail.spec.tsx rename to packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx diff --git a/packages/client/ui-attachment/tests/image-lightbox.spec.tsx b/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx similarity index 100% rename from packages/client/ui-attachment/tests/image-lightbox.spec.tsx rename to packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx diff --git a/packages/client/ui-attachment/tests/invariant.spec.ts b/packages/client/ui-attachment/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-attachment/tests/invariant.spec.ts rename to packages/client/ui-attachment/tests/invariant.client.spec.ts diff --git a/packages/client/ui-attachment/tests/message-image.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx similarity index 100% rename from packages/client/ui-attachment/tests/message-image.spec.tsx rename to packages/client/ui-attachment/tests/message-image.client.spec.tsx diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-command/tests/browser-plugin.spec.ts rename to packages/client/ui-command/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-command/tests/directory.spec.ts b/packages/client/ui-command/tests/directory.client.spec.ts similarity index 100% rename from packages/client/ui-command/tests/directory.spec.ts rename to packages/client/ui-command/tests/directory.client.spec.ts diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.client.spec.tsx similarity index 100% rename from packages/client/ui-command/tests/popup-view.spec.tsx rename to packages/client/ui-command/tests/popup-view.client.spec.tsx diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.client.spec.ts similarity index 100% rename from packages/client/ui-command/tests/popup.spec.ts rename to packages/client/ui-command/tests/popup.client.spec.ts diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.client.spec.ts similarity index 100% rename from packages/client/ui-command/tests/service.spec.ts rename to packages/client/ui-command/tests/service.client.spec.ts diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/apply-inject.spec.tsx rename to packages/client/ui-conversation/tests/apply-inject.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx rename to packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/chat-apply.spec.tsx rename to packages/client/ui-conversation/tests/chat-apply.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx rename to packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index 5d1f027923..31897294b6 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -24,7 +24,7 @@ import { import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' -import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { diff --git a/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts b/packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts similarity index 100% rename from packages/client/ui-conversation/tests/chat-snapshot-fixture.ts rename to packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/chat-stats.spec.tsx rename to packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index 0b2d648661..b53a1aa739 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -14,7 +14,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { en, zh } from '../src/client/locales.ts' -import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/chat-store.spec.ts rename to packages/client/ui-conversation/tests/chat-store.client.spec.ts diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/chat-view.spec.tsx rename to packages/client/ui-conversation/tests/chat-view.client.spec.tsx index f1d095ab7e..1dad50cb68 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -32,7 +32,7 @@ import { } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' -import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' afterEach(() => { cleanup() diff --git a/packages/client/ui-conversation/tests/context-meter.spec.tsx b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/context-meter.spec.tsx rename to packages/client/ui-conversation/tests/context-meter.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts rename to packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/coverage-tails.spec.tsx rename to packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx rename to packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx rename to packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx index bcda26f6a8..a2af45fbd0 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx @@ -17,7 +17,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch import { StatsLine } from '../src/client/chat/StatsLine.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { zh } from '../src/client/locales.ts' -import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/host.spec.ts rename to packages/client/ui-conversation/tests/host.client.spec.ts diff --git a/packages/client/ui-conversation/tests/image-labels.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/image-labels.spec.tsx rename to packages/client/ui-conversation/tests/image-labels.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/input-bar.spec.tsx rename to packages/client/ui-conversation/tests/input-bar.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/input-machine.spec.ts rename to packages/client/ui-conversation/tests/input-machine.client.spec.ts diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/input-matrix.spec.tsx rename to packages/client/ui-conversation/tests/input-matrix.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/input-scenarios.spec.tsx rename to packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 1eabde8cb8..76bbf5ebd3 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -16,7 +16,7 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' -import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.ts' +import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.client.ts' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { SessionInputShell } from '../src/client/input/facade.ts' diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/queue-dock.spec.tsx rename to packages/client/ui-conversation/tests/queue-dock.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/reasoning-row.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/reasoning-row.spec.tsx rename to packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/selection-survival.spec.tsx rename to packages/client/ui-conversation/tests/selection-survival.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/service-orchestration.spec.ts rename to packages/client/ui-conversation/tests/service-orchestration.client.spec.ts diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/skeleton.spec.tsx rename to packages/client/ui-conversation/tests/skeleton.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/submission-policy.spec.ts rename to packages/client/ui-conversation/tests/submission-policy.client.spec.ts diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/todo-panel.spec.tsx rename to packages/client/ui-conversation/tests/todo-panel.client.spec.tsx diff --git a/packages/client/ui-conversation/tests/turn-metrics.spec.ts b/packages/client/ui-conversation/tests/turn-metrics.client.spec.ts similarity index 100% rename from packages/client/ui-conversation/tests/turn-metrics.spec.ts rename to packages/client/ui-conversation/tests/turn-metrics.client.spec.ts diff --git a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-conversation/tests/views-type-chain.spec.tsx rename to packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx similarity index 100% rename from packages/client/ui-deliverables/tests/produced-files.spec.tsx rename to packages/client/ui-deliverables/tests/produced-files.client.spec.tsx diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx similarity index 100% rename from packages/client/ui-directory-picker-native/tests/client-flow.spec.tsx rename to packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx diff --git a/packages/client/ui-directory-picker/tests/client-flow.spec.tsx b/packages/client/ui-directory-picker/tests/client-flow.client.spec.tsx similarity index 100% rename from packages/client/ui-directory-picker/tests/client-flow.spec.tsx rename to packages/client/ui-directory-picker/tests/client-flow.client.spec.tsx diff --git a/packages/client/ui-directory-picker/tests/directory-browser.spec.tsx b/packages/client/ui-directory-picker/tests/directory-browser.client.spec.tsx similarity index 100% rename from packages/client/ui-directory-picker/tests/directory-browser.spec.tsx rename to packages/client/ui-directory-picker/tests/directory-browser.client.spec.tsx diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx similarity index 100% rename from packages/client/ui-goal/tests/browser-plugin.spec.tsx rename to packages/client/ui-goal/tests/browser-plugin.client.spec.tsx diff --git a/packages/client/ui-goal/tests/goal-command-input.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx similarity index 100% rename from packages/client/ui-goal/tests/goal-command-input.spec.tsx rename to packages/client/ui-goal/tests/goal-command-input.client.spec.tsx diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx similarity index 100% rename from packages/client/ui-goal/tests/goalbar.spec.tsx rename to packages/client/ui-goal/tests/goalbar.client.spec.tsx diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx similarity index 100% rename from packages/client/ui-layout/tests/app-frame.spec.tsx rename to packages/client/ui-layout/tests/app-frame.client.spec.tsx diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-layout/tests/apply.spec.ts rename to packages/client/ui-layout/tests/apply.client.spec.ts diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.client.spec.ts similarity index 100% rename from packages/client/ui-layout/tests/columns.spec.ts rename to packages/client/ui-layout/tests/columns.client.spec.ts diff --git a/packages/client/ui-layout/tests/layout-store.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts similarity index 100% rename from packages/client/ui-layout/tests/layout-store.spec.ts rename to packages/client/ui-layout/tests/layout-store.client.spec.ts diff --git a/packages/client/ui-layout/tests/service.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts similarity index 100% rename from packages/client/ui-layout/tests/service.spec.ts rename to packages/client/ui-layout/tests/service.client.spec.ts diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.client.spec.ts similarity index 100% rename from packages/client/ui-layout/tests/theme-presenter.spec.ts rename to packages/client/ui-layout/tests/theme-presenter.client.spec.ts diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-model/tests/browser-plugin.spec.ts rename to packages/client/ui-model/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.client.spec.tsx similarity index 100% rename from packages/client/ui-model/tests/model-select.spec.tsx rename to packages/client/ui-model/tests/model-select.client.spec.tsx diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-models/tests/apply.spec.ts rename to packages/client/ui-models/tests/apply.client.spec.ts diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.client.spec.tsx similarity index 100% rename from packages/client/ui-models/tests/components.spec.tsx rename to packages/client/ui-models/tests/components.client.spec.tsx diff --git a/packages/client/ui-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-models/tests/invariant.spec.ts rename to packages/client/ui-models/tests/invariant.client.spec.ts diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.client.spec.tsx similarity index 100% rename from packages/client/ui-models/tests/onboarding-dialog.spec.tsx rename to packages/client/ui-models/tests/onboarding-dialog.client.spec.tsx diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.client.spec.tsx similarity index 100% rename from packages/client/ui-models/tests/provider-form.spec.tsx rename to packages/client/ui-models/tests/provider-form.client.spec.tsx diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.client.spec.ts similarity index 100% rename from packages/client/ui-models/tests/readiness.spec.ts rename to packages/client/ui-models/tests/readiness.client.spec.ts diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.client.spec.ts similarity index 100% rename from packages/client/ui-models/tests/store.spec.ts rename to packages/client/ui-models/tests/store.client.spec.ts diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.client.spec.ts similarity index 100% rename from packages/client/ui-models/tests/styles.spec.ts rename to packages/client/ui-models/tests/styles.client.spec.ts diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-permission/tests/browser-plugin.spec.ts rename to packages/client/ui-permission/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.client.spec.tsx similarity index 100% rename from packages/client/ui-permission/tests/permission-row.spec.tsx rename to packages/client/ui-permission/tests/permission-row.client.spec.tsx diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.client.spec.ts similarity index 100% rename from packages/client/ui-permission/tests/settings-store.spec.ts rename to packages/client/ui-permission/tests/settings-store.client.spec.ts diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-plan/tests/browser-plugin.spec.ts rename to packages/client/ui-plan/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx similarity index 100% rename from packages/client/ui-plan/tests/plan-mode-control.spec.tsx rename to packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx diff --git a/packages/client/ui-plugin-config/tests/apply.spec.ts b/packages/client/ui-plugin-config/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-plugin-config/tests/apply.spec.ts rename to packages/client/ui-plugin-config/tests/apply.client.spec.ts diff --git a/packages/client/ui-plugin-config/tests/fields.spec.tsx b/packages/client/ui-plugin-config/tests/fields.client.spec.tsx similarity index 100% rename from packages/client/ui-plugin-config/tests/fields.spec.tsx rename to packages/client/ui-plugin-config/tests/fields.client.spec.tsx diff --git a/packages/client/ui-plugin-config/tests/invariant.spec.ts b/packages/client/ui-plugin-config/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-plugin-config/tests/invariant.spec.ts rename to packages/client/ui-plugin-config/tests/invariant.client.spec.ts diff --git a/packages/client/ui-plugin-config/tests/section.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx similarity index 100% rename from packages/client/ui-plugin-config/tests/section.spec.tsx rename to packages/client/ui-plugin-config/tests/section.client.spec.tsx diff --git a/packages/client/ui-plugin-config/tests/stores.spec.ts b/packages/client/ui-plugin-config/tests/stores.client.spec.ts similarity index 100% rename from packages/client/ui-plugin-config/tests/stores.spec.ts rename to packages/client/ui-plugin-config/tests/stores.client.spec.ts diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.client.spec.ts similarity index 100% rename from packages/client/ui-primitives/tests/ansi.spec.ts rename to packages/client/ui-primitives/tests/ansi.client.spec.ts diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/atoms.spec.tsx rename to packages/client/ui-primitives/tests/atoms.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/code-block.spec.tsx rename to packages/client/ui-primitives/tests/code-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/diff-block.spec.tsx rename to packages/client/ui-primitives/tests/diff-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/hover-card.spec.tsx rename to packages/client/ui-primitives/tests/hover-card.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/icons.spec.tsx rename to packages/client/ui-primitives/tests/icons.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/invariant.spec.ts b/packages/client/ui-primitives/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-primitives/tests/invariant.spec.ts rename to packages/client/ui-primitives/tests/invariant.client.spec.ts diff --git a/packages/client/ui-primitives/tests/json-tree.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/json-tree.spec.tsx rename to packages/client/ui-primitives/tests/json-tree.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx rename to packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown-incremental.spec.tsx rename to packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown-plain-text.spec.ts b/packages/client/ui-primitives/tests/markdown-plain-text.client.spec.ts similarity index 100% rename from packages/client/ui-primitives/tests/markdown-plain-text.spec.ts rename to packages/client/ui-primitives/tests/markdown-plain-text.client.spec.ts diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown-render-units.spec.tsx rename to packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/markdown.spec.tsx rename to packages/client/ui-primitives/tests/markdown.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/onboarding-surface.spec.tsx b/packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/onboarding-surface.spec.tsx rename to packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/read-block.spec.tsx b/packages/client/ui-primitives/tests/read-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/read-block.spec.tsx rename to packages/client/ui-primitives/tests/read-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/search-block.spec.tsx rename to packages/client/ui-primitives/tests/search-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/state-dot.spec.tsx rename to packages/client/ui-primitives/tests/state-dot.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/terminal-block.spec.tsx rename to packages/client/ui-primitives/tests/terminal-block.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/toast.spec.tsx b/packages/client/ui-primitives/tests/toast.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/toast.spec.tsx rename to packages/client/ui-primitives/tests/toast.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/tooltip.spec.tsx rename to packages/client/ui-primitives/tests/tooltip.client.spec.tsx diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.client.spec.tsx similarity index 100% rename from packages/client/ui-primitives/tests/web-block.spec.tsx rename to packages/client/ui-primitives/tests/web-block.client.spec.tsx diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-question/tests/browser-plugin.spec.ts rename to packages/client/ui-question/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-question/tests/node-plugin.spec.ts rename to packages/client/ui-question/tests/node-plugin.client.spec.ts diff --git a/packages/client/ui-question/tests/plan-review-panel.spec.tsx b/packages/client/ui-question/tests/plan-review-panel.client.spec.tsx similarity index 100% rename from packages/client/ui-question/tests/plan-review-panel.spec.tsx rename to packages/client/ui-question/tests/plan-review-panel.client.spec.tsx diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.client.spec.tsx similarity index 100% rename from packages/client/ui-question/tests/question-composer.spec.tsx rename to packages/client/ui-question/tests/question-composer.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/apply.spec.ts rename to packages/client/ui-settings-general/tests/apply.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/components.spec.tsx rename to packages/client/ui-settings-general/tests/components.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/host.spec.ts b/packages/client/ui-settings-general/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/host.spec.ts rename to packages/client/ui-settings-general/tests/host.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/invariant.spec.ts rename to packages/client/ui-settings-general/tests/invariant.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/settings-document-store.spec.ts rename to packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/settings-root.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/settings-root.spec.tsx rename to packages/client/ui-settings-general/tests/settings-root.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/shell.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/shell.spec.ts rename to packages/client/ui-settings-general/tests/shell.client.spec.ts diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.client.spec.tsx similarity index 100% rename from packages/client/ui-settings-general/tests/welcome-notice.spec.tsx rename to packages/client/ui-settings-general/tests/welcome-notice.client.spec.tsx diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.client.spec.ts similarity index 100% rename from packages/client/ui-settings-general/tests/welcome-store.spec.ts rename to packages/client/ui-settings-general/tests/welcome-store.client.spec.ts diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/invariant.spec.ts rename to packages/client/ui-settings/tests/invariant.client.spec.ts diff --git a/packages/client/ui-settings/tests/plugin.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/plugin.spec.ts rename to packages/client/ui-settings/tests/plugin.client.spec.ts diff --git a/packages/client/ui-settings/tests/settings-scope.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts similarity index 100% rename from packages/client/ui-settings/tests/settings-scope.spec.ts rename to packages/client/ui-settings/tests/settings-scope.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap similarity index 100% rename from packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap rename to packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/apply.spec.tsx rename to packages/client/ui-sidebar/tests/apply.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/invariant.spec.ts b/packages/client/ui-sidebar/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/invariant.spec.ts rename to packages/client/ui-sidebar/tests/invariant.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx rename to packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts b/packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts rename to packages/client/ui-sidebar/tests/scrollbar-quiet-styles.client.spec.ts diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-root.spec.tsx rename to packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx rename to packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/sidebar-styles.spec.ts rename to packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-skill/tests/browser-plugin.spec.ts rename to packages/client/ui-skill/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx similarity index 100% rename from packages/client/ui-skill/tests/skill-row.spec.tsx rename to packages/client/ui-skill/tests/skill-row.client.spec.tsx diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/apply.spec.ts rename to packages/client/ui-slash/tests/apply.client.spec.ts diff --git a/packages/client/ui-slash/tests/core-detect.spec.ts b/packages/client/ui-slash/tests/core-detect.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/core-detect.spec.ts rename to packages/client/ui-slash/tests/core-detect.client.spec.ts diff --git a/packages/client/ui-slash/tests/core-menu.spec.ts b/packages/client/ui-slash/tests/core-menu.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/core-menu.spec.ts rename to packages/client/ui-slash/tests/core-menu.client.spec.ts diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.client.spec.tsx similarity index 100% rename from packages/client/ui-slash/tests/menu-view.spec.tsx rename to packages/client/ui-slash/tests/menu-view.client.spec.tsx diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.client.spec.ts similarity index 100% rename from packages/client/ui-slash/tests/service.spec.ts rename to packages/client/ui-slash/tests/service.client.spec.ts diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/core.spec.ts rename to packages/client/ui-slots/tests/core.client.spec.ts diff --git a/packages/client/ui-slots/tests/dynamic-keys.spec.ts b/packages/client/ui-slots/tests/dynamic-keys.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/dynamic-keys.spec.ts rename to packages/client/ui-slots/tests/dynamic-keys.client.spec.ts diff --git a/packages/client/ui-slots/tests/invariant.spec.ts b/packages/client/ui-slots/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-slots/tests/invariant.spec.ts rename to packages/client/ui-slots/tests/invariant.client.spec.ts diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-slots/tests/type-chain.spec.tsx rename to packages/client/ui-slots/tests/type-chain.client.spec.tsx diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-subagent/tests/browser-plugin.spec.ts rename to packages/client/ui-subagent/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx similarity index 100% rename from packages/client/ui-subagent/tests/conversation-ui.spec.tsx rename to packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx diff --git a/packages/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.client.spec.ts similarity index 100% rename from packages/client/ui-task/tests/browser-plugin.spec.ts rename to packages/client/ui-task/tests/browser-plugin.client.spec.ts diff --git a/packages/client/ui-task/tests/task-list-action.spec.tsx b/packages/client/ui-task/tests/task-list-action.client.spec.tsx similarity index 100% rename from packages/client/ui-task/tests/task-list-action.spec.tsx rename to packages/client/ui-task/tests/task-list-action.client.spec.tsx diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx similarity index 100% rename from packages/client/ui-theme/tests/appearance-row.spec.tsx rename to packages/client/ui-theme/tests/appearance-row.client.spec.tsx diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/apply.spec.ts rename to packages/client/ui-theme/tests/apply.client.spec.ts diff --git a/packages/client/ui-theme/tests/boot-theme.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/boot-theme.spec.ts rename to packages/client/ui-theme/tests/boot-theme.client.spec.ts diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/host.spec.ts rename to packages/client/ui-theme/tests/host.client.spec.ts diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/invariant.spec.ts rename to packages/client/ui-theme/tests/invariant.client.spec.ts diff --git a/packages/client/ui-theme/tests/scrollbar-styles.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/scrollbar-styles.spec.ts rename to packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/settings-store.spec.ts rename to packages/client/ui-theme/tests/settings-store.client.spec.ts diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.client.spec.ts similarity index 100% rename from packages/client/ui-theme/tests/theme.spec.ts rename to packages/client/ui-theme/tests/theme.client.spec.ts diff --git a/packages/client/ui-tool/tests/ask-question-row.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/ask-question-row.spec.tsx rename to packages/client/ui-tool/tests/ask-question-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/assembly-surfaces.spec.tsx rename to packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 80ee97d157..668b1bda89 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -8,7 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx rename to packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index fbdfc4b75e..61a15dd283 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -25,7 +25,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' const SID = 's1' as SessionId diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/coverage-tails.spec.tsx rename to packages/client/ui-tool/tests/coverage-tails.client.spec.tsx diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/diff-card.spec.tsx rename to packages/client/ui-tool/tests/diff-card.client.spec.tsx index c37b5bb540..6708d3806a 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -22,7 +22,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/read-card.spec.tsx rename to packages/client/ui-tool/tests/read-card.client.spec.tsx index b86b17924b..6351c5c312 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -26,7 +26,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' afterEach(cleanup) diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/search-card.spec.tsx rename to packages/client/ui-tool/tests/search-card.client.spec.tsx index 16e127c7ca..cc3a1acf72 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -25,7 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ type SearchRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/terminal-card.spec.tsx rename to packages/client/ui-tool/tests/terminal-card.client.spec.tsx index cb00ea4b93..ad351dfabe 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -22,7 +22,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] diff --git a/packages/client/ui-tool/tests/todo-row.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/todo-row.spec.tsx rename to packages/client/ui-tool/tests/todo-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-call-tree.spec.tsx rename to packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-details-render.tsx rename to packages/client/ui-tool/tests/tool-details-render.client.tsx diff --git a/packages/client/ui-tool/tests/tool-row-styles.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts similarity index 100% rename from packages/client/ui-tool/tests/tool-row-styles.spec.ts rename to packages/client/ui-tool/tests/tool-row-styles.client.spec.ts diff --git a/packages/client/ui-tool/tests/tool-row.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/tool-row.spec.tsx rename to packages/client/ui-tool/tests/tool-row.client.spec.tsx diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/toolview-slot.spec.tsx rename to packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 2ab4ba9b12..afd714c74f 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -18,7 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' -import { toolChatSnapshot } from './tool-details-render.tsx' +import { toolChatSnapshot } from './tool-details-render.client.tsx' const SID = 's1' as SessionId diff --git a/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx similarity index 100% rename from packages/client/ui-tool/tests/toolview-type-chain.spec.tsx rename to packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx similarity index 99% rename from packages/client/ui-tool/tests/web-card.spec.tsx rename to packages/client/ui-tool/tests/web-card.client.spec.tsx index cc46918694..8a3efd2627 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -25,7 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/cell.spec.tsx rename to packages/client/ui-trajectory/tests/cell.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/client-bundle.spec.ts rename to packages/client/ui-trajectory/tests/client-bundle.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/conversation-definitions.spec.ts rename to packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/export-log.spec.ts rename to packages/client/ui-trajectory/tests/export-log.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/layout.spec.tsx rename to packages/client/ui-trajectory/tests/layout.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/snapshot-builder.spec.ts rename to packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/table.spec.tsx rename to packages/client/ui-trajectory/tests/table.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/toolbar.spec.tsx rename to packages/client/ui-trajectory/tests/toolbar.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx similarity index 100% rename from packages/client/ui-trajectory/tests/views.spec.tsx rename to packages/client/ui-trajectory/tests/views.client.spec.tsx diff --git a/packages/client/ui-trajectory/tests/virtual-rows.spec.ts b/packages/client/ui-trajectory/tests/virtual-rows.client.spec.ts similarity index 100% rename from packages/client/ui-trajectory/tests/virtual-rows.spec.ts rename to packages/client/ui-trajectory/tests/virtual-rows.client.spec.ts diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx similarity index 100% rename from packages/client/ui-workflow-run/tests/workflow-run.spec.tsx rename to packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/apply.spec.ts rename to packages/client/ui-workspace/tests/apply.client.spec.ts diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/browser-styles.spec.ts rename to packages/client/ui-workspace/tests/browser-styles.client.spec.ts diff --git a/packages/client/ui-workspace/tests/invariant.spec.ts b/packages/client/ui-workspace/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/invariant.spec.ts rename to packages/client/ui-workspace/tests/invariant.client.spec.ts diff --git a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/rename-assembly.spec.tsx rename to packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/rows.spec.tsx rename to packages/client/ui-workspace/tests/rows.client.spec.tsx diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts similarity index 100% rename from packages/client/ui-workspace/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.client.spec.ts diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/workspace-browser.spec.tsx rename to packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx similarity index 100% rename from packages/client/ui-workspace/tests/workspace-picker.spec.tsx rename to packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx diff --git a/packages/client/web-react/tests/bind.spec.tsx b/packages/client/web-react/tests/bind.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/bind.spec.tsx rename to packages/client/web-react/tests/bind.client.spec.tsx diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/scoped-slots-real-core.spec.tsx rename to packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/scoped-slots.spec.tsx rename to packages/client/web-react/tests/scoped-slots.client.spec.tsx diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/session-provider.spec.tsx rename to packages/client/web-react/tests/session-provider.client.spec.tsx diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/stale-authorization.spec.tsx rename to packages/client/web-react/tests/stale-authorization.client.spec.tsx diff --git a/packages/client/web-react/tests/use-invoke.spec.tsx b/packages/client/web-react/tests/use-invoke.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/use-invoke.spec.tsx rename to packages/client/web-react/tests/use-invoke.client.spec.tsx diff --git a/packages/client/web-react/tests/use-projection.spec.tsx b/packages/client/web-react/tests/use-projection.client.spec.tsx similarity index 100% rename from packages/client/web-react/tests/use-projection.spec.tsx rename to packages/client/web-react/tests/use-projection.client.spec.tsx diff --git a/packages/client/web/tests/app-root.spec.tsx b/packages/client/web/tests/app-root.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app-root.spec.tsx rename to packages/client/web/tests/app-root.client.spec.tsx diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app-shell.spec.tsx rename to packages/client/web/tests/app-shell.client.spec.tsx diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.client.spec.tsx similarity index 100% rename from packages/client/web/tests/app.spec.tsx rename to packages/client/web/tests/app.client.spec.tsx diff --git a/packages/client/web/tests/base-styles.spec.ts b/packages/client/web/tests/base-styles.client.spec.ts similarity index 100% rename from packages/client/web/tests/base-styles.spec.ts rename to packages/client/web/tests/base-styles.client.spec.ts diff --git a/packages/client/web/tests/document-title.spec.tsx b/packages/client/web/tests/document-title.client.spec.tsx similarity index 100% rename from packages/client/web/tests/document-title.spec.tsx rename to packages/client/web/tests/document-title.client.spec.tsx diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index d6f84efd63..3d3410e09a 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -89,9 +89,9 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] }, - { file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/section.client.spec.tsx', upstream: ['cordis'] }, { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, @@ -126,7 +126,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. - { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, + { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, // The preset id the shipped composition documents to its own model. { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, @@ -330,7 +330,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ { // The real package references in files whose other `cordis` strings are preset ids. id: 'agent-preset-spec-framework-import', - file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', + file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', find: "import { Context } from 'cordis'", replace: "import { Context } from '@deepseek-ai/cordis'", expect: 1, diff --git a/tsconfig.client.json b/tsconfig.client.json index 9e2a7778d7..ce48a77ea9 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -24,6 +24,12 @@ "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], + // A `*.host.spec.ts` covers the Host half of a split client package and + // belongs to the host aggregate, which excludes this program's `*.client.*` + // in turn. `exclude` wins over `include`, so the test glob above stays broad. + "exclude": [ + "packages/client/*/tests/**/*.host.spec.ts" + ], "references": [ // Shared leaf: web e2e boots the real host webserver (fixture + real-host // smoke policy). webserver has zero workspace deps and no cordis merge, @@ -44,9 +50,6 @@ { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, - // The carrier's node-half spec rides this aggregate's package test glob, so - // its Host face is referenced here too — the mirror of the webserver leaf. - { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index ef02fc557f..d4e7f7ba3b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -87,8 +87,17 @@ "website/**/*.ts", "website/.vitepress/**/*.ts" ], + // Under packages/client a test file names the face it covers: `*.client.*` + // belongs to the Client aggregate, `*.host.spec.ts` to this one. The two + // suffixes are mutually exclusive, so each aggregate excludes the other's + // and the package test glob above needs no per-file entry. "exclude": [ - "packages/client/**", + "packages/client/*/src/**", + "packages/client/*/tests/**/*.client.ts", + "packages/client/*/tests/**/*.client.tsx", + "packages/client/*/tests/**/*.client.spec.ts", + "packages/client/*/tests/**/*.client.spec.tsx", + "packages/client/tsdown.client.ts", "packages/api/gateway/tests/client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", From 2c2d9f26e524c1265895cbac7b22d9e65731d51f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:15:58 +0800 Subject: [PATCH 42/46] fix: client spec --- apps/web/tests/composer-draft-scroll.e2e.ts | 2 +- apps/web/tests/goal-multi-turn-actions.e2e.ts | 2 +- packages/client/connection/src/client/fixture.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 0ec3ef63e1..ae48dcddca 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -24,7 +24,7 @@ // // Only a real engine can show any of this. Scrolling is layout: jsdom reports // `scrollHeight === clientHeight` for every element and never scrolls one, so -// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can +// the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can // only assert that one scrollport contains both layers. // // Zero model calls: a fresh workspace's blank session already carries a live diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 8896ed3c42..d133897967 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -29,7 +29,7 @@ const PACKAGE_FILES: Readonly> = { 'packages/client/ui-conversation/README.md': '# UI conversation\n', 'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n', 'packages/client/ui-conversation/src/client.ts': 'export {}\n', - 'packages/client/ui-conversation/tests/chat-view.spec.tsx': 'export {}\n', + 'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n', 'packages/context/session-reference/README.md': '# Session reference\n', 'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n', 'packages/context/session-reference/src/index.ts': 'export {}\n', diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index fdd45120a4..46a362a150 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -116,7 +116,7 @@ const TERMINAL_OUTPUT_FIXTURE = [ `${sgr(32, '\u2713')} duplication 2.10s`, `${sgr(31, '\u2717')} unit 8.41s`, '', - sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'), + sgr(90, 'packages/client/ui-primitives/tests/terminal-block.client.spec.tsx'), ` ${sgr(31, 'FAIL')} caps output at the configured line budget`, ' expected 16 lines, received 24', '', @@ -201,7 +201,7 @@ const SEARCH_PATHS_FIXTURE = [ 'packages/client/ui-primitives/src/SearchBlock.module.css', 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts', 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx', - 'packages/client/ui-tool/tests/search-card.spec.tsx', + 'packages/client/ui-tool/tests/search-card.client.spec.tsx', ] /** From d91180db9a8b42aa36143fb004be7c387915effe Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:59:50 +0800 Subject: [PATCH 43/46] docs: client spec --- ...-a-provider-from-the-models-page.i18n.yaml | 4 +- ...claring-a-provider-from-the-models-page.md | 2 +- ...ring-a-provider-from-the-models-page.zh.md | 2 +- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 2 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 2 +- ...-07-30-hover-popup-pointer-grace.i18n.yaml | 4 +- .../2026-07-30-hover-popup-pointer-grace.md | 2 +- ...2026-07-30-hover-popup-pointer-grace.zh.md | 2 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 2 +- ...text-layers-share-one-scrollport.i18n.yaml | 4 +- ...mposer-text-layers-share-one-scrollport.md | 2 +- ...ser-text-layers-share-one-scrollport.zh.md | 2 +- ...ontext-meter-blind-to-compaction.i18n.yaml | 4 +- ...08-05-context-meter-blind-to-compaction.md | 2 +- ...05-context-meter-blind-to-compaction.zh.md | 2 +- ...e-blank-session-reuse-membership.i18n.yaml | 4 +- ...orkspace-blank-session-reuse-membership.md | 2 +- ...space-blank-session-reuse-membership.zh.md | 2 +- ...rding-step-owned-takeover-chrome.i18n.yaml | 4 +- ...6-onboarding-step-owned-takeover-chrome.md | 2 +- ...nboarding-step-owned-takeover-chrome.zh.md | 2 +- ...-attribution-observed-top-ledger.i18n.yaml | 4 +- ...-scroll-attribution-observed-top-ledger.md | 2 +- ...roll-attribution-observed-top-ledger.zh.md | 2 +- .../2026-07-23-web-todo-display.i18n.yaml | 4 +- .../feature/2026-07-23-web-todo-display.md | 2 +- .../feature/2026-07-23-web-todo-display.zh.md | 2 +- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 2 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 2 +- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 4 +- .../2026-07-28-web-terminal-card.zh.md | 4 +- .../2026-07-30-web-diff-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-diff-card.md | 4 +- .../feature/2026-07-30-web-diff-card.zh.md | 4 +- ...026-07-30-web-read-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-read-card-frontend.md | 4 +- .../2026-07-30-web-read-card-frontend.zh.md | 4 +- ...6-07-30-web-result-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-result-card-frontend.md | 4 +- .../2026-07-30-web-result-card-frontend.zh.md | 4 +- .../2026-07-30-web-search-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-search-card.md | 4 +- .../feature/2026-07-30-web-search-card.zh.md | 4 +- ...6-08-02-web-thinking-tail-scroll.i18n.yaml | 4 +- .../2026-08-02-web-thinking-tail-scroll.md | 2 +- .../2026-08-02-web-thinking-tail-scroll.zh.md | 2 +- ...6-08-03-web-search-source-scroll.i18n.yaml | 4 +- .../2026-08-03-web-search-source-scroll.md | 2 +- .../2026-08-03-web-search-source-scroll.zh.md | 2 +- ...nter-revealed-sidebar-scrollbars.i18n.yaml | 4 +- ...-04-pointer-revealed-sidebar-scrollbars.md | 4 +- ...-pointer-revealed-sidebar-scrollbars.zh.md | 4 +- ...-12-face-named-client-test-files.i18n.yaml | 6 ++ ...2026-08-12-face-named-client-test-files.md | 61 +++++++++++++++++++ ...6-08-12-face-named-client-test-files.zh.md | 61 +++++++++++++++++++ 60 files changed, 216 insertions(+), 88 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-12-face-named-client-test-files.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-12-face-named-client-test-files.md create mode 100644 .agents/notes/implemented/process/2026-08-12-face-named-client-test-files.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml index b7fc5cca1d..4c832ba450 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md -2026-08-04-declaring-a-provider-from-the-models-page.md: 3e7ff9c3bd58b73185c669a224a59cd017ecfb42 -2026-08-04-declaring-a-provider-from-the-models-page.zh.md: a4255f8e29b696e84ff3accf636714cdfc0855f1 +2026-08-04-declaring-a-provider-from-the-models-page.md: 179a31396d34228e7a0cd510edac4c535af3162e +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 3fca60554769fbf5f5be0d2bc76edf3ab3b71578 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md index 3e7ff9c3bd..179a31396d 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -48,4 +48,4 @@ What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is th ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `` that takes `.input` without `.selectInput`, because the OS arrow it would otherwise keep sits flush inside the 240px cap `select.input` imposes. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md index a4255f8e29..3fca605547 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -48,4 +48,4 @@ Status: implemented ## Testing -`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。样式 gate 读取本包自己的源码,任何只取 `.input` 而不取 `.selectInput` 的 `` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index 852d3816e2..95b52ad014 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 8ad5d801358823576c37d6b6824d7ce3fdcdac11 -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: d6d2c64703df95ea2a4ff79435512eabdb3d2f81 +2026-07-28-themed-scrollbars-and-reserved-gutter.md: a820a92406ce4054f16922064772d03c6ac3ab83 +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 80104c4aca5986c3a1f49186ac4adf3169f46da5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 8ad5d80135..a820a92406 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,7 +20,7 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were initially missed, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index d6d2c64703..80104c4aca 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,7 +20,7 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定约定,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定约定,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 `Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面最初被漏掉,因此逐样式表的重新绑定约定由机械检查而非人工审阅把关。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml index 6e364d9a3e..4db552343c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md -2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 -2026-07-30-hover-popup-pointer-grace.zh.md: f53d77bae7b7f223621c4c42779e1a6a616aa485 +2026-07-30-hover-popup-pointer-grace.md: 302592a2e9de8e749ef2450e8877a3ccbffd15d5 +2026-07-30-hover-popup-pointer-grace.zh.md: 4020a6f1f7ab52b6340a07f4c099906a7353a8a5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md index 3f60c98ec6..302592a2e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -32,4 +32,4 @@ The hover card is now hit-testable and covers 244px of whatever it overlays whil ## Testing -`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. +`packages/client/ui-primitives/tests/hover-card.client.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md index f53d77bae7..4020a6f1f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -32,4 +32,4 @@ Status: implemented ## 测试 -`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 +`packages/client/ui-primitives/tests/hover-card.client.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 8245d59db4..82df356c6b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 1c65eeec0fbf0dc550043bb809ae8d7310f0602b -2026-07-30-web-transcript-log-ordered-projection.zh.md: 67bb41adde46ba7112896404ddc92a05f08f7aca +2026-07-30-web-transcript-log-ordered-projection.md: f2eda6983ab88a420a4506be7766320dc5da0fa2 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 4c8bb5d0b9b49762d12aba837cef9c0818740cb0 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 1c65eeec0f..f2eda6983a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. +`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 67bb41adde..4c8bb5d0b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 +`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index d9a9857cc8..92dcd28a7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: ba11384409714d6a64a964d63197705acf39d213 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 7a2500a6253c7cf50a57458b66c9b80503414ace +2026-07-31-composer-text-layers-share-one-scrollport.md: 6097779529f86e6d994296ae396f108c63f01abc +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 753d67d538d0c17512444639d60b7b5c8ff80e9a diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index ba11384409..6097779529 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -71,7 +71,7 @@ Revealing the caret is the one thing that now depends on the browser rather than ## Testing -The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's. +The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's. [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures the rest in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls. Every metric is read in the caret's own coordinate frame — where the textarea places line n, offset included — against a DOM Range over the backdrop's text for the same line, because that difference is what a user sees. The decisive case changes the offset and re-reads that difference **before the task ends**, which is before any `scroll` listener could have run: 0 with one scrollport, and the full delta with a mirror. A vacuity guard asserts the draft overflows the capped box first, and separate cases cover the cap, one wrap width across all three layers, a wheel gesture, a trailing-newline draft, and the caret-reveal path that the textarea's own scrolling used to handle — typing after scrolling away must bring the scrollport back to the caret. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 7a2500a625..753d67d538 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -71,7 +71,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl ## 测试 -[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 +[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分:全新工作区的空白 composer 中一份 40 行草稿,零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪,含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比,因为这个差值正是用户看到的东西。决定性的用例改变偏移,并**在本任务结束之前**重新读取该差值,也就是在任何 `scroll` 监听可能运行之前:单一滚动容器下为 0,镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子;其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿,以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index d40263ad98..2a780fd889 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: 10ded250cec9c92d90803bdf5969cc7f5aa54c50 -2026-08-05-context-meter-blind-to-compaction.zh.md: ca3fe59eecde82bb97b44af4c3383546d5672753 +2026-08-05-context-meter-blind-to-compaction.md: b90208eda25101aeb55130cd1b6563dbfaee8a9a +2026-08-05-context-meter-blind-to-compaction.zh.md: 85387c50ebc981fc2f7f02d07432da677f69ef90 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index 10ded250ce..b90208eda2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -43,4 +43,4 @@ The panel's composition rows still do not sum to the header, and now for one cle ## Testing -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.client.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index ca3fe59eec..85387c50eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -43,4 +43,4 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 测试 -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了投影值在表层增长与一次压缩期间的延续更新(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.client.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index e82314c28b..41d1df6605 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 0581b768679d78716c1bf70cfffec825c7281783 +2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722 +2026-08-05-workspace-blank-session-reuse-membership.zh.md: e67810dc3a45d494f64a2b774557d04297b6fce3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 910a10e9ad..0d46a0ccf6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them) ## Testing -`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. +`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 0581b76867..e67810dc3a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 测试 -`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 +`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml index f789ed37a0..33f4d398c9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md -2026-08-06-onboarding-step-owned-takeover-chrome.md: 35b9d16aaba4ca9f124a9972c43b2a107ea0309f -2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 2f3331965aa94aa01bf0ce21d4bbb421397ee5bd +2026-08-06-onboarding-step-owned-takeover-chrome.md: 97f5335412f256bf7e60f36731c9e0c4475ee99e +2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 054072764a7b9a25586899a93596fdc922e71dcb diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md index 35b9d16aab..97f5335412 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md @@ -32,4 +32,4 @@ A future step that registers without wrapping its visible content in `Onboarding ## Testing -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. +`packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.client.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md index 2f3331965a..054072764a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md @@ -32,4 +32,4 @@ ## 测试 -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 +`packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.client.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml index d7e6553fd3..f25c5622fa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md -2026-08-06-reader-scroll-attribution-observed-top-ledger.md: ef5ddbeb9bea1393f474dfb4c809ae2e19bfea5c -2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 7a1142cdd793cfce46bfd3908ab28345701aec7a +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: ce7cd6d0ade2702a6f717340cb7c9b354d0387c2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md index ef5ddbeb9b..66a1ca361c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the ## Testing -Unit specs in `packages/client/ui-conversation/tests/chat-view.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. +Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md index 7a1142cdd7..ce7cd6d0ad 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输 ## 测试 -`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 +`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 5c05f0b86d..bf3d66fcf3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: bb66ef8512badea090b3b22030eef3f43f3b1119 -2026-07-23-web-todo-display.zh.md: 5b3534e20956a4412814a782532d1337c08ff027 +2026-07-23-web-todo-display.md: 3d6720bca80cbcfa4d163d91c0fdffe5e7ed624f +2026-07-23-web-todo-display.zh.md: 692993731d921ed9502dc66d1ca60e1ed0648c0a diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index bb66ef8512..3d6720bca8 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 5b3534e209..692993731d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -33,4 +33,4 @@ Status: implemented ## 后果 -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 9f98597809..e7ed85f4c6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 5664960ce94dfac60d89cb363ea62459dd51f59e -2026-07-26-todo-parallel-in-progress.zh.md: f346f89fcae5e8607c5608bbf7541ada0bae4593 +2026-07-26-todo-parallel-in-progress.md: 3d7b80e94762f8ca24b716c42619bf20d9a13608 +2026-07-26-todo-parallel-in-progress.zh.md: 28e32c949de09b5c0722cd1c7e9d91c1542a6cf7 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 5664960ce9..3d7b80e947 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -51,4 +51,4 @@ Two known gaps are deferred. The `summarySuffix` span carries no accessible name ## Consequences -A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. +A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index f346f89fca..28e32c949d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -51,4 +51,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 +现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.client.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 781c07f8f3..3148aa5836 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 84e5cdb786944b014fd3a687cfefad6768174006 -2026-07-28-web-terminal-card.zh.md: 58c236bda9c45e13fda0f965f84af24b3c831bb4 +2026-07-28-web-terminal-card.md: ef6da52b4166b61579cf3ef98e760c2f1c42b679 +2026-07-28-web-terminal-card.zh.md: 80c555f227b4e6dd96adcfb666f52e9bb42e2d41 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 84e5cdb786..ef6da52b41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -55,9 +55,9 @@ Inline rendering is licensed for the terminal intent alone. A future intent that ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. +`packages/client/ui-primitives/tests/ansi.client.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.client.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. -`packages/client/ui-tool/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. +`packages/client/ui-tool/tests/terminal-card.client.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. `apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 is named `bash` and turn 60 stays `fx-bash` so one fixture covers both shapes, and turn 60's command is two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's two prompt rows cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 58c236bda9..80c555f227 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -55,9 +55,9 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 +`packages/client/ui-primitives/tests/ansi.client.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.client.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 -`packages/client/ui-tool/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 +`packages/client/ui-tool/tests/terminal-card.client.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 `apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮名为 `bash`、第 60 轮保持 `fx-bash`,于是一份 fixture 覆盖两种形态,而第 60 轮的命令为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 549145aad0..a37a306181 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: d8b0cf32e42af45e16e6f7059c873c384d4a9229 -2026-07-30-web-diff-card.zh.md: 51df03c3f8b79cd8415a15bd30e92e3c152f335e +2026-07-30-web-diff-card.md: 465a6fc9e2fbe61fd1a2e6f11590d01e2553d506 +2026-07-30-web-diff-card.zh.md: d674b1a323f15f52592b51b6188e0b80d1ba977c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index d8b0cf32e4..465a6fc9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -44,9 +44,9 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc ## Testing -`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. +`packages/client/ui-primitives/tests/diff-block.client.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%. -`packages/client/ui-tool/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. +`packages/client/ui-tool/tests/diff-card.client.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index 51df03c3f8..d674b1a323 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -44,9 +44,9 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX ## Testing -`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 +`packages/client/ui-primitives/tests/diff-block.client.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。 -`packages/client/ui-tool/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 +`packages/client/ui-tool/tests/diff-card.client.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身约定它不带 diff 行为断言,那由接线套件负责。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml index 9e159b5b40..13091086c4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md -2026-07-30-web-read-card-frontend.md: 1d9b6d7fa0c8b5391d175a53507db88a84234b91 -2026-07-30-web-read-card-frontend.zh.md: 2678d50e851e4b3a079f59c7db6ec08ecc5c4aed +2026-07-30-web-read-card-frontend.md: 98e31d4192f7522f9d0e23bce56372a70f8c50b6 +2026-07-30-web-read-card-frontend.zh.md: 294821ec9f1b35f58c399aa77d21b629557cd059 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md index 1d9b6d7fa0..98e31d4192 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -40,9 +40,9 @@ A read row in the Web chat now carries the file content resident, a deliberate d ## Testing -`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs. +`packages/client/ui-primitives/tests/read-block.client.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs. -`packages/client/ui-tool/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure. +`packages/client/ui-tool/tests/read-card.client.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure. The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md index 2678d50e85..294821ec9f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md @@ -40,9 +40,9 @@ Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行 ## Testing -`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。 +`packages/client/ui-primitives/tests/read-block.client.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。 -`packages/client/ui-tool/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。 +`packages/client/ui-tool/tests/read-card.client.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。 fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml index 37debadbe3..03a676161c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md -2026-07-30-web-result-card-frontend.md: c048c4f93f72e4a1da5c6426497e9274d65ac266 -2026-07-30-web-result-card-frontend.zh.md: a7090818dc023f857e01e43773adccfba585cc55 +2026-07-30-web-result-card-frontend.md: 6a00e0820af2c4010554df444a7585fb226294bf +2026-07-30-web-result-card-frontend.zh.md: 727466dbbde1a7d06673713f635e16769eda00e0 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md index c048c4f93f..6a00e0820a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -36,9 +36,9 @@ The whole-row collapse/expand interaction shared by every resident card (termina ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `

  • ` numbering every source contiguously from 1. +`packages/client/ui-primitives/tests/web-block.client.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. -`packages/client/ui-tool/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring boundary: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. +`packages/client/ui-tool/tests/web-card.client.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring boundary: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index a7090818dc..727466dbbd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -36,9 +36,9 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 +`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 -`packages/client/ui-tool/tests/web-card.spec.tsx` 在每个接线边界镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 +`packages/client/ui-tool/tests/web-card.client.spec.tsx` 在每个接线边界镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配约定的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 79bb4d0fb4..a818d7c294 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md -2026-07-30-web-search-card.md: 23c89585770151551526223454e98d18716d1a44 -2026-07-30-web-search-card.zh.md: f92bd9f9231d947d4c70e283b462294c030e3f33 +2026-07-30-web-search-card.md: 930a705fc227d7961e9cb1a51b0b04774a62c6a4 +2026-07-30-web-search-card.zh.md: 83904c8f583af6a0ef3b24bbca2336c457644f6c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 23c8958577..930a705fc2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -54,9 +54,9 @@ Three sites consume the derivation, mirroring the terminal card's placement exac ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. +`packages/client/ui-primitives/tests/search-block.client.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-tool/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. +`packages/client/ui-tool/tests/search-card.client.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index f92bd9f923..83904c8f58 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -54,9 +54,9 @@ Status: implemented ## Testing -`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 +`packages/client/ui-primitives/tests/search-block.client.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-tool/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库约定要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按约定只测启动)无法捕获它。 +`packages/client/ui-tool/tests/search-card.client.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库约定要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按约定只测启动)无法捕获它。 ## Related diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index 484df46a31..d0cb4e3cd9 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: 18e94b2e0075bf7099b9b48177de2e274896942a -2026-08-02-web-thinking-tail-scroll.zh.md: 7bafeb0f64f05a41c502aba8f479e391c8b395f0 +2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941 +2026-08-02-web-thinking-tail-scroll.zh.md: 34a0a39f88a9511a1c93d0b21b3ac13903848e98 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index 18e94b2e00..b9aa47a01b 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index 7bafeb0f64..34a0a39f88 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 +`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml index 49a14093ef..b77b574232 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md -2026-08-03-web-search-source-scroll.md: f6b118b91d3d2102e11fa7b6d068621e961a7674 -2026-08-03-web-search-source-scroll.zh.md: 58eae594bd18c58f7fb80322f6902abdbba49ba7 +2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374 +2026-08-03-web-search-source-scroll.zh.md: 34ee46c6837cc81740d3f74df1f2c6eae689adf2 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md index f6b118b91d..3402f519e1 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -36,7 +36,7 @@ Every source the tool returned is always in the DOM, so no source the view carri ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
  • ` with no `[aria-expanded]` and no `