diff --git a/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.i18n.yaml b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.i18n.yaml new file mode 100644 index 0000000000..4a697578a2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.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/process/2026-08-23-client-cross-package-value-dependencies.md +2026-08-23-client-cross-package-value-dependencies.md: b4db6c75e245db37848d0389631441a585c82188 +2026-08-23-client-cross-package-value-dependencies.zh.md: 9894f743112157017c7d675eeac8adcb3aacff82 diff --git a/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.md b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.md new file mode 100644 index 0000000000..b4db6c75e2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.md @@ -0,0 +1,49 @@ +# Agent Note: Classifying Client cross-package value dependencies + +Status: implemented + +English | [中文](2026-08-23-client-cross-package-value-dependencies.zh.md) + +## Problem + +The Client package splits in [PR #2728](https://github.com/deepseek-ai/deepseek-harness/pull/2728) and [PR #2911](https://github.com/deepseek-ai/deepseek-harness/pull/2911) left 15 `dsh.client.external` requests in feature-plugin manifests. Those requests turned ordinary value imports into synchronous module-table ordering constraints, even when the consumer needed only a type, a small pure conversion, or access to an already-injected Cordis service. + +Removing every import mechanically would create different coupling: a general utility package could become a miscellaneous business owner, a service could carry pure presentation transforms, or duplicated target behavior could be centralized only to satisfy clone detection. Client maintenance needs one repeatable classification before choosing where a cross-package reference belongs. + +## Decision + +Every Client cross-package reference is classified by what crosses the package boundary. A feature plugin does not import a runtime value from another feature plugin and does not declare `dsh.client.external`. The [Client shell layering decision](../architecture/2026-08-15-client-shells-and-dynamic-packages.md) continues to own bundle construction and module-table loading; this decision narrows how feature code uses those mechanisms. + +| Case | Treatment | Reason | +| --- | --- | --- | +| Unused value or forwarding export | Delete it | A dependency without a caller has no owner to preserve. | +| Shared declaration | Import it with `import type` from the declaring package | Erased imports retain one type authority without a runtime edge. | +| Stateful, lifecycle-bound, or callable feature behavior | Expose it through an injected Cordis service | The providing plugin owns implementation and lifecycle; consumers depend on the service name and interface. | +| Presentation contribution | Register it through the declaring slot | The owner controls placement while contributors remain independently loadable. | +| Generic stateless helper or primitive | Put it in a narrow static utility package or `ui-primitives` | Multiple packages may synchronously share behavior only when it has no feature state, lifecycle, or domain authority. | +| Small target-specific projection | Keep one local implementation in each target | Chat and Trajectory may intentionally interpret the same durable event independently; sharing code alone does not justify a feature dependency. | +| Generated Remote artifact | Import it only in the API transport assembly that owns generated registration | Generated providers are transport wiring, not a feature package's callable helper API. | + +Intentional target-local copies wrap only the duplicated implementation in `jscpd:ignore-start` / `jscpd:ignore-end`, with a comment naming the independent owners. The exclusion must not cover surrounding business logic. Generic behavior moves to a utility only when its semantics are stable outside every current caller; this cleanup places Workspace path formatting in `dsh-util-workspace-path`, byte encoding in `dsh-util-crypto`, and the shared reference glyph in `ui-primitives`. + +`verify-client-packages` rejects every `dsh.client.external` declaration under `packages/client/*`. Outside that feature tree, each declaration must correspond to a production runtime import or re-export. The two retained requests are Session Controller → API Gateway and Workspace Controller → API Gateway; both are transport infrastructure. The Client bundle preset separately rejects workspace runtime imports that are neither module-table requests nor explicitly allowlisted static inputs. + +Host-facing transport adapters remain outside the feature-plugin prohibition. Connection may use API Proxy's carrier implementation, and `api/remotes` may load a generated Host Remote provider. These imports assemble transport rather than sharing feature behavior. + +## Alternatives considered + +**Put every reused value on `uiConversation`.** Rejected because pure event-to-view conversions would become service calls or feature exports, forcing Chat, Trajectory, Approval, Question, Subagent, and Workspace to load an unrelated feature owner. + +**Keep feature `dsh.client.external` declarations.** Rejected because successful loading would preserve the synchronous value dependency and merely make its ordering explicit. + +**Move every repeated function into one utility package.** Rejected because target-specific interpretation would acquire a false shared owner. Only state-free behavior with meaning independent of its callers belongs in a static utility. + +**Ignore all duplicate Client code.** Rejected because duplication remains useful evidence by default. An ignore is narrow and documents the deliberate independence of named targets. + +## Consequences + +The 15 feature-plugin external requests are absent, while shared declaration imports remain explicit and type-only. Feature loading order follows Cordis services and slots instead of synchronous feature-module imports. + +Some short projection functions exist twice. Their owners can evolve independently, and clone detection still covers all code outside the annotated copies. Static utility packages gain a small public API and must remain state-free and browser-safe. + +The rule is role-specific rather than a blanket ban on cross-package values. Infrastructure adapters and generated registration artifacts remain direct imports where loading or protocol assembly requires them, and `verify-client-packages` keeps those exceptions visible and live. diff --git a/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.zh.md b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.zh.md new file mode 100644 index 0000000000..9894f74311 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Client 跨包值依赖分类 + +Status: implemented + +[English](2026-08-23-client-cross-package-value-dependencies.md) | 中文 + +## 问题 + +[PR #2728](https://github.com/deepseek-ai/deepseek-harness/pull/2728) 与 [PR #2911](https://github.com/deepseek-ai/deepseek-harness/pull/2911) 拆分 Client 包后,功能插件 manifest 中还留有 15 条 `dsh.client.external` 请求。即使消费方只需要一个类型、一段小型纯转换或访问已经注入的 Cordis service,这些请求也会把普通值 import 变成同步模块表顺序约束。 + +机械删除所有 import 会产生别的耦合:通用工具包可能变成杂项业务 owner,service 可能承载纯展示转换,或者只为通过重复检测而把 target 行为集中到一处。维护 Client 时,需要先用同一套流程分类,再决定跨包引用应当放在哪里。 + +## 决策 + +每条 Client 跨包引用都按实际跨越包边界的内容分类。功能插件不从另一个功能插件导入运行时值,也不声明 `dsh.client.external`。[Client shell 分层决策](../architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md)继续负责 bundle 构建与模块表加载;本决策进一步限定功能代码如何使用这些机制。 + +| 情形 | 处理方式 | 原因 | +| --- | --- | --- | +| 未使用的值或转发 export | 删除 | 没有调用方的依赖不需要保留 owner。 | +| 共享声明 | 从声明方包使用 `import type` 导入 | 被擦除的 import 保留单一类型权威,但不产生运行时边。 | +| 有状态、受生命周期约束或可调用的功能行为 | 通过注入的 Cordis service 暴露 | 提供插件拥有实现与生命周期;消费方只依赖 service 名称和接口。 | +| 展示贡献 | 通过声明方 slot 注册 | owner 控制放置位置,各贡献方仍可独立加载。 | +| 通用无状态辅助函数或基础组件 | 放入窄职责静态工具包或 `ui-primitives` | 只有不持有功能状态、生命周期或领域权威的行为才允许被多个包同步共享。 | +| 小型 target 专属投影 | 每个 target 保留一份本地实现 | Chat 与 Trajectory 可以独立解释同一持久事件;仅仅复用代码不足以证明应建立功能依赖。 | +| 生成的 Remote 产物 | 只在拥有生成注册的 API 传输组装层导入 | 生成的 provider 是传输接线,不是功能包的可调用辅助 API。 | + +有意保留的 target 本地副本只用 `jscpd:ignore-start`/`jscpd:ignore-end` 包住重复实现,并在注释中点名相互独立的 owner;排除范围不得覆盖周围业务逻辑。只有语义独立于所有当前调用方时,通用行为才进入工具包;本次清理把 Workspace 路径格式化放入 `dsh-util-workspace-path`,把字节编码放入 `dsh-util-crypto`,把共享引用图标放入 `ui-primitives`。 + +`verify-client-packages` 拒绝 `packages/client/*` 下的所有 `dsh.client.external` 声明。在该功能树之外,每条声明都必须对应生产代码中的运行时 import 或 re-export。保留的两条请求是 Session Controller → API Gateway 与 Workspace Controller → API Gateway,二者都属于传输基础设施。Client bundle preset 还会拒绝既非模块表请求、也未被明确加入静态输入 allowlist 的 workspace 运行时 import。 + +面向 Host 的传输适配器不属于功能插件禁令。Connection 可以使用 API Proxy 的 carrier 实现,`api/remotes` 可以加载生成的 Host Remote provider;这些 import 用于组装传输,而不是共享功能行为。 + +## 考虑过的替代方案 + +**把所有复用值都放到 `uiConversation`。** 否决,因为纯 event→view 转换会变成 service 调用或功能 export,迫使 Chat、Trajectory、Approval、Question、Subagent 与 Workspace 加载一个无关的功能 owner。 + +**保留功能插件的 `dsh.client.external` 声明。** 否决,因为加载成功只会把同步值依赖的顺序显式化,不会消除该依赖。 + +**把每个重复函数都移入同一个工具包。** 否决,因为 target 专属解释会因此获得一个虚假的共享 owner。只有语义独立于调用方的无状态行为才属于静态工具。 + +**忽略全部 Client 重复代码。** 否决,因为重复默认仍是有用信号。每项 ignore 必须范围狭窄,并说明哪些具名 target 需要有意保持独立。 + +## 后果 + +15 条功能插件 external 请求已移除,共享声明 import 保持显式且仅类型化。功能加载顺序由 Cordis service 与 slot 决定,不再由同步功能模块 import 决定。 + +少量投影函数存在两份实现。各 owner 可以独立演进,重复检测仍覆盖注解副本以外的全部代码。静态工具包增加少量公共 API,并且必须保持无状态且可在浏览器运行。 + +这项规则按包角色区分,并非全面禁止跨包值。加载或协议组装需要的基础设施适配器与生成注册产物仍保留直接 import,`verify-client-packages` 则确保这些例外保持可见且确实仍被使用。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e26e81787d..7f2bc49362 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 06d5163566816eb732b51ce784cd2ab92218d4fd -architecture.zh.md: 55162553b62300e5e1bb34bab468cb3956877e56 +architecture.md: e37f2321377242803fb89f0a2258682e6c52801c +architecture.zh.md: 69abbcdf52654ecbce6549d25ee089b937228123 diff --git a/docs/architecture.md b/docs/architecture.md index 06d5163566..e37f232137 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,4 +140,4 @@ New behavior attaches to a documented extension point. Changing the loop itself | Fork a live session | `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a registration to one agent | use that agent's `agent.ctx` | -The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [settings cards](cookbook/adding-a-settings-card.md). +The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [settings cards](cookbook/adding-a-settings-card.md). The [Conversation subsystem](subsystems/conversation.md) owns Chat-node assembly. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 55162553b6..69abbcdf52 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -144,4 +144,4 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | fork 活跃会话 | `ctx.sessions.fork(source, boundary?, childSessionId?)` | | 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` | -[扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)、[Chat 节点](cookbook/adding-a-conversation-node.zh.md)和[设置卡片](cookbook/adding-a-settings-card.zh.md)的分步指南。 +[扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)和[设置卡片](cookbook/adding-a-settings-card.zh.md)的分步指南。[Conversation 子系统](subsystems/conversation.zh.md)负责 Chat node 组装。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8ab6ab4460..f11b12b5ba 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: 163608f410ea034a9adfe4f73a1015b4ab5cafc5 -config-catalog.zh.md: a4f48470a2f9f608a14d8447f42efe27863ef506 +config-catalog.md: 9685f4689a6ebd6994c1fc6dccbae04af1658a15 +config-catalog.zh.md: 51575325b3656487e2fe852a6c8cf0b47b2b6fe8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 163608f410..9685f4689a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3384,4 +3384,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-util-crypto` ([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) +- `@deepseek-ai/dsh-util-workspace-path` ([`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts)) - `@deepseek-ai/dsh-win32-process` ([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a4f48470a2..51575325b3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3385,4 +3385,5 @@ export interface Config { - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-util-crypto`([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) +- `@deepseek-ai/dsh-util-workspace-path`([`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts)) - `@deepseek-ai/dsh-win32-process`([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index f0a5136bf2..273ba36ca7 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 1081166674cb946675d8864091c50def018f9f97 -extension-cookbook.zh.md: defdda53e8f8ea849d2f236566bef48751f4ad96 +extension-cookbook.md: 2fe03506d5b89eff544bbb6dea10a8b6429dfe3e +extension-cookbook.zh.md: 506793219988418618e2ae499369d43dabfd20b6 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 1081166674..2fe03506d5 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -34,7 +34,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an ## A UI plugin -A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation Node guide](adding-a-conversation-node.md). +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation subsystem reference](../subsystems/conversation.md). ```ts import type { Context } from '@deepseek-ai/cordis' diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index defdda53e8..5067932199 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -36,7 +36,7 @@ export function apply(ctx: Context) { ## UI 插件 -UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体步骤见 [Conversation Node 指南](adding-a-conversation-node.zh.md)。 +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体约定见 [Conversation 子系统参考](../subsystems/conversation.zh.md)。 ```ts import type { Context } from '@deepseek-ai/cordis' diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index b979af0f72..90fea5010f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 130e7d204e613591f2b6ab31136083cc49d3bdd0 -module-graph.zh.md: 673c3f2f9d6117d200205d0127e87711f043ed85 +module-graph.md: 0d26be0a33b6aeca80fd04d9fdabc8f977a714df +module-graph.zh.md: 396d5879e26c8da1446d83323991de13acf0bd61 diff --git a/docs/module-graph.md b/docs/module-graph.md index 130e7d204e..0d26be0a33 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -16,6 +16,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] + pkg_util_workspace_path["util-workspace-path"] end subgraph group_llm["packages/llm"] pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] @@ -359,6 +360,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_util_workspace_path --> pkg_invariants pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants pkg_cmdline --> pkg_invariants @@ -1256,6 +1258,7 @@ flowchart TD pkg_api_session_controller --> pkg_tools pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection @@ -1355,6 +1358,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_token_meter pkg_client_ui_conversation --> pkg_tool_todo pkg_client_ui_conversation --> pkg_util_crypto + pkg_client_ui_conversation --> pkg_util_workspace_path pkg_client_ui_conversation --> pkg_workspace pkg_client_ui_sidebar --> pkg_api_workspace_controller pkg_client_ui_sidebar --> pkg_client_locale @@ -1373,6 +1377,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_sidebar pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1445,6 +1450,7 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_ui_session pkg_client_ui_trajectory --> pkg_compaction pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_llm pkg_client_ui_trajectory --> pkg_session pkg_client_ui_trajectory --> pkg_tools pkg_client_ui_user_questions --> pkg_api_remotes @@ -1478,6 +1484,8 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools + pkg_client_ui_chat --> pkg_util_crypto + pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller pkg_client_ui_commands --> pkg_client_locale @@ -1587,6 +1595,7 @@ flowchart TD pkg_client_ui_tool --> pkg_client_ui_renderer pkg_client_ui_tool --> pkg_client_ui_session pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_tool --> pkg_util_workspace_path pkg_client_ui_workflow_run --> pkg_api_session_controller pkg_client_ui_workflow_run --> pkg_client_locale pkg_client_ui_workflow_run --> pkg_client_ui_chat @@ -1642,6 +1651,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1832,7 +1842,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1844,9 +1854,9 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1856,9 +1866,9 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | @@ -1870,7 +1880,7 @@ flowchart TD | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 673c3f2f9d..396d5879e2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -18,6 +18,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] + pkg_util_workspace_path["util-workspace-path"] end subgraph group_llm["packages/llm"] pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] @@ -361,6 +362,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_util_workspace_path --> pkg_invariants pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants pkg_cmdline --> pkg_invariants @@ -1258,6 +1260,7 @@ flowchart TD pkg_api_session_controller --> pkg_tools pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection @@ -1357,6 +1360,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_token_meter pkg_client_ui_conversation --> pkg_tool_todo pkg_client_ui_conversation --> pkg_util_crypto + pkg_client_ui_conversation --> pkg_util_workspace_path pkg_client_ui_conversation --> pkg_workspace pkg_client_ui_sidebar --> pkg_api_workspace_controller pkg_client_ui_sidebar --> pkg_client_locale @@ -1375,6 +1379,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_sidebar pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1447,6 +1452,7 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_ui_session pkg_client_ui_trajectory --> pkg_compaction pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_llm pkg_client_ui_trajectory --> pkg_session pkg_client_ui_trajectory --> pkg_tools pkg_client_ui_user_questions --> pkg_api_remotes @@ -1480,6 +1486,8 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools + pkg_client_ui_chat --> pkg_util_crypto + pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller pkg_client_ui_commands --> pkg_client_locale @@ -1589,6 +1597,7 @@ flowchart TD pkg_client_ui_tool --> pkg_client_ui_renderer pkg_client_ui_tool --> pkg_client_ui_session pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_tool --> pkg_util_workspace_path pkg_client_ui_workflow_run --> pkg_api_session_controller pkg_client_ui_workflow_run --> pkg_client_locale pkg_client_ui_workflow_run --> pkg_client_ui_chat @@ -1644,6 +1653,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1834,7 +1844,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1846,9 +1856,9 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1858,9 +1868,9 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | @@ -1872,7 +1882,7 @@ flowchart TD | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index c46916bf5f..e43820301d 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: fabd6c1075280c955b1cf7e3afaea0df6fa98992 -README.zh.md: 686ed0a5dcd469dfef60eea7b2e679fd4220e666 +README.md: 2b277650c4b9e320183f75845d68f922b31a522e +README.zh.md: 2279857e0d58cb36e8027c196075622344d705ae diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index fabd6c1075..2b277650c4 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -51,7 +51,10 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [webhook.md](webhook.md) | authenticated provider deliveries, arbitrary programmatic rules, and fire-and-forget Workspace Session creation | | [storage.md](storage.md) | the storage subsystem: the backend contract (`StorageBackend`), `StorageForms`, `DomainSpec`/`Domain`, `domain/changed` | | [workspace.md](workspace.md) | the workspace registry: `Workspace`/`WorkspaceId`, registration and resolution, the session `cwd` relationship | +| [web-client.md](web-client.md) | the browser architecture: boot, Remote communication, paired Client models, UI adapters, Conversation assembly, Slots, and reconnect semantics | | [client-modules.md](client-modules.md) | the web plugin table: `dsh.client` declarations, `WebBootGraph` wire composition, the bundle route and index tap | +| [slots.md](slots.md) | typed Web UI composition: declaration ownership, cardinality and scope, framework and feature injection, props derivation, and the shipped hierarchy | +| [conversation.md](conversation.md) | target-neutral Session-event assembly: Context identity, Location data, replay paths, view builders, and target-owned render nodes | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | | [session-telemetry.md](session-telemetry.md) | the outbound session-reporting capability seam: `SessionTelemetryRecord`/`SessionTelemetrySeverity`, the `SessionTelemetrySink` contract, and the `session-telemetry/record` redact waterfall | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 686ed0a5dc..2279857e0d 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -51,7 +51,10 @@ | [webhook.md](webhook.zh.md) | 通过身份验证的提供方交付、任意程序化规则,以及 fire-and-forget 的 Workspace Session 创建 | | [storage.md](storage.zh.md) | 存储子系统:后端约定(`StorageBackend`)、`StorageForms`、`DomainSpec`/`Domain`、`domain/changed` | | [workspace.md](workspace.zh.md) | 工作区注册表:`Workspace`/`WorkspaceId`、注册与解析、与会话 `cwd` 的关系 | +| [web-client.md](web-client.zh.md) | 浏览器架构:启动、Remote 通信、配对的 Client model、UI adapter、Conversation 组装、Slots 与重连语义 | | [client-modules.md](client-modules.zh.md) | Web 插件表:`dsh.client` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | +| [slots.md](slots.zh.md) | 类型化 Web UI 组合:声明所有权、cardinality 与 scope、框架与功能注入、props 推导及当前层级 | +| [conversation.md](conversation.zh.md) | target-neutral Session event 组装:Context identity、Location data、replay 路径、view builder 与 target 自有 render node | | [session-projection.md](session-projection.zh.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | | [session-telemetry.md](session-telemetry.zh.md) | 对外会话上报能力 seam:`SessionTelemetryRecord`/`SessionTelemetrySeverity`、`SessionTelemetrySink` 约定和 `session-telemetry/record` 脱敏 waterfall | diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/subsystems/conversation.i18n.yaml similarity index 50% rename from docs/cookbook/adding-a-conversation-node.i18n.yaml rename to docs/subsystems/conversation.i18n.yaml index e51fcf65c5..dc3e51e636 100644 --- a/docs/cookbook/adding-a-conversation-node.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-conversation-node.md -adding-a-conversation-node.md: daa86f90473cb7023a21e1cfa25339fcd79aa558 -adding-a-conversation-node.zh.md: 8c6360a99acb49aebadf9a28f440853b18514b9a +# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md +conversation.md: d26abf73292faacdf3a4186819c4738d1de0270e +conversation.zh.md: 7fff9e0433b0022c75a35d3885398f241818a21d diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/subsystems/conversation.md similarity index 81% rename from docs/cookbook/adding-a-conversation-node.md rename to docs/subsystems/conversation.md index daa86f9047..d26abf7329 100644 --- a/docs/cookbook/adding-a-conversation-node.md +++ b/docs/subsystems/conversation.md @@ -1,12 +1,26 @@ -# Add a Web Client conversation node +# Conversation assembly -English | [中文](adding-a-conversation-node.zh.md) +English | [中文](conversation.zh.md) -This tutorial adds one business-owned row to the Web Client Chat view. The finished plugin correlates a durable Session event family into one Context, incrementally builds business State, publishes typed Step data, and renders a keyed Chat Node without scanning the Session window or other rendered nodes. It assumes the Host already records the events and the client plugin is composed into the Web bundle; external Host-side UIs and additional view targets such as Trajectory are outside this tutorial. +Conversation is the target-neutral assembly layer between a Client Session event window and browser views. [`ui-conversation`](../../packages/client/ui-conversation/README.md) owns the event and view registries, one identity-stable binding per `SessionBinding`, Turn/Step locations, incremental Context assembly, target sources, the shared shell, and input orchestration. Target packages such as [`ui-chat`](../../packages/client/ui-chat/README.md) and [`ui-trajectory`](../../packages/client/ui-trajectory/README.md) own their Definitions, final snapshots, and rendering. -The [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns the rationale and complete engine model. This guide covers the implementation path. +This page defines the data model and the extension path for a business-owned Conversation node. The [Web Client architecture](web-client.md) places the subsystem between Client models and Slots; the [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns its rationale. -## 1. Design a replayable event family +## Data model and ownership + +The Session Controller owns the contiguous loaded event window. `ui-conversation` observes that existing source and converts each entry to `{ event, view? }`; it never opens a second history stream. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target. + +| Concept | Owner and purpose | +|---|---| +| Event Definition | A business package matches one event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. | +| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. Update-only evidence may remain pending until pagination supplies its unique start. | +| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. | +| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. | +| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. | + +Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence. + +## Replayable event families Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context. @@ -22,7 +36,7 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events. -## 2. Implement the Definition and typed Chat payload +## Definition and typed Chat payload The example keeps the producer declarations and client contribution in one block so the complete relationship is visible. In a package family, keep the branded id and `SessionEventMap` declaration with the event producer, and keep the Definition, Chat data merge, and renderer in the client plugin. @@ -89,7 +103,7 @@ interface ReviewChatData { readonly summary?: string } -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { 'review-job': ReviewChatData } @@ -200,13 +214,13 @@ export function apply(ctx: ClientContext): void { `target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. -## 3. Query an earlier business Context only at start +## Predecessor reads Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data. The assembler records that dependency. If an older prepend later supplies a nearer predecessor, closes a previously unknown window gap, or revises the predecessor State, it reruns the dependent Context from `start` and replays its updates in ascending `seq`. The queried Definition remains responsible for writing useful State; the reader exposes no business-specific query methods and grants no mutation authority over another Context. -## 4. Understand the three ingestion paths +## Window update paths History may be requested from the tail backward one page at a time, but every accepted page is normalized into ascending `seq` before State replay. @@ -220,7 +234,7 @@ With `D` registered Definitions, one incoming event performs `D` current-event m `publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine still applies every update in log order; cadence only coalesces view publication. -## 5. Verify replay, pagination, and rendering +## Verification obligations Add focused tests that establish these outcomes: diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/subsystems/conversation.zh.md similarity index 81% rename from docs/cookbook/adding-a-conversation-node.zh.md rename to docs/subsystems/conversation.zh.md index 8c6360a99a..7fff9e0433 100644 --- a/docs/cookbook/adding-a-conversation-node.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -1,12 +1,26 @@ -# 添加 Web Client Conversation Node +# Conversation 组装 -[English](adding-a-conversation-node.md) | 中文 +[English](conversation.md) | 中文 -本教程为 Web Client Chat 视图添加一行由业务自行拥有的内容。完成后的插件会把一个持久 Session 事件族关联成一个 Context,增量构造业务 State,发布类型化 Step 数据,再渲染 keyed Chat Node;整个过程不扫描 Session 窗口或其他已渲染节点。本教程假设 Host 已经记录这些事件,且该 Client 插件已组装进 Web bundle;Host 侧外部 UI 和 Trajectory 等额外视图目标不在本文范围内。 +Conversation 是 Client Session event window 与浏览器 view 之间的 target-neutral assembly 层。[`ui-conversation`](../../packages/client/ui-conversation/README.zh.md)拥有 event 与 view registry、每个 `SessionBinding` 对应的 identity-stable binding、Turn/Step Location、增量 Context assembly、target source、共享 shell 与输入编排。[`ui-chat`](../../packages/client/ui-chat/README.zh.md)和 [`ui-trajectory`](../../packages/client/ui-trajectory/README.zh.md)等 target 包拥有各自的 Definition、最终 snapshot 与渲染。 -[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md)记录完整的引擎模型和设计理由;本文只说明实现路径。 +本文定义数据模型与业务自有 Conversation node 的扩展路径。[Web Client 架构](web-client.zh.md)说明该子系统在 Client model 与 Slots 之间的位置;[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md)记录其设计理由。 -## 1. 设计可回放的事件族 +## 数据模型与所有权 + +Session Controller 拥有连续的已加载 event window。`ui-conversation` 观察这一个现有 source,并把每个 entry 转换为 `{ event, view? }`;它绝不另开一条 history stream。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。 + +| 概念 | Owner 与用途 | +|---|---| +| Event Definition | 业务包一次匹配一条 event,以稳定 `(kind, id)` 关联事件、折叠确定性 State,并可选择 materialize 一个 target node。 | +| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。只有 update 的证据可以保持 pending,直到分页补齐其唯一 start。 | +| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 | +| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 | +| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 | + +Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。 + +## 可回放 event family 编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 @@ -22,7 +36,7 @@ 系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。 -## 2. 实现 Definition 与类型化 Chat payload +## Definition 与类型化 Chat payload 为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。 @@ -89,7 +103,7 @@ interface ReviewChatData { readonly summary?: string } -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { 'review-job': ReviewChatData } @@ -200,13 +214,13 @@ export function apply(ctx: ClientContext): void { `target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 -## 3. 只在 start 时查询更早的业务 Context +## Predecessor read 有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。 Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的前序 Context、补齐了原先未知的窗口缺口,或者前序 State 被修订,引擎会从 `start` 重新运行依赖方 Context,并按 `seq` 升序回放其 update。被查询的 Definition 仍负责把有用信息写入自身 State;Reader 不提供业务专用查询方法,也不授予修改其他 Context 的权限。 -## 4. 理解三条摄入路径 +## Window 更新路径 历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 `seq` 升序归一化,再进入 State 回放。 @@ -220,7 +234,7 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 `publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎仍会按日志顺序应用每条 update;该选项只合并视图发布频率。 -## 5. 验证回放、分页与渲染 +## 验证要求 添加聚焦测试,证明以下结果: diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index b101b92344..0afa4d58c5 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 7d80adfc25e3ebb9f482a3e1c84c16163dba318e -session.zh.md: 6337a54bd221a3908793c2231bcde4af8a879bb9 +session.md: edb8f4ebb427bfce6e4def023e65f4697608ceb2 +session.zh.md: 7b3c7a8e50688ba19694d5f45e43d224c2245ef1 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 7d80adfc25..edb8f4ebb4 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -573,7 +573,7 @@ Consumers that order Sessions by human activity exclude this boundary: picking a A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The generated [persistence log event catalog](../persistence-catalog.md) enumerates every core and plugin-contributed event with its payload, surface badge, and declaration site; the compaction seam's `compaction/*` semantics are discussed on [compaction.md](compaction.md). -When several events in one plugin-owned family assemble into one Web Client Conversation Node, every start, update, result, resource, or interruption event in that family carries or independently derives the same stable business id. This requirement applies to correlated Node families, not to every Session event; it lets the client group each event without guessing from adjacency or scanning history. See the [Conversation Node cookbook](../cookbook/adding-a-conversation-node.md). +When several events in one plugin-owned family assemble into one Web Client Conversation Node, every start, update, result, resource, or interruption event in that family carries or independently derives the same stable business id. This requirement applies to correlated Node families, not to every Session event; it lets the client group each event without guessing from adjacency or scanning history. See the [Conversation subsystem](conversation.md). The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 6337a54bd2..7b3c7a8e50 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -577,7 +577,7 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。生成的[持久化日志事件目录](../persistence-catalog.zh.md)会列出每个核心或插件贡献的事件,以及其 payload、surface 标记和声明位置;压缩 seam 的 `compaction/*` 语义在 [compaction.md](compaction.zh.md) 中讨论。 -如果同一个插件事件族中的多条事件要组装成一个 Web Client Conversation Node,该事件族中的每条 start、update、result、resource 或 interruption 事件都必须携带或独立推导出同一个稳定业务 id。此要求只约束需要关联的 Node 事件族,并不要求每条 Session 事件都有业务 id;Client 因此无须根据相邻关系猜测归属,也无须扫描历史。参见 [Conversation Node 实操手册](../cookbook/adding-a-conversation-node.zh.md)。 +如果同一个插件事件族中的多条事件要组装成一个 Web Client Conversation Node,该事件族中的每条 start、update、result、resource 或 interruption 事件都必须携带或独立推导出同一个稳定业务 id。此要求只约束需要关联的 Node 事件族,并不要求每条 Session 事件都有业务 id;Client 因此无须根据相邻关系猜测归属,也无须扫描历史。参见 [Conversation 子系统](conversation.zh.md)。 钩子桥接层的 `hook/invoked` / `hook/result` 对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md))。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml new file mode 100644 index 0000000000..ad6907574f --- /dev/null +++ b/docs/subsystems/slots.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 docs/subsystems/slots.md +slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5 +slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md new file mode 100644 index 0000000000..d201223c9e --- /dev/null +++ b/docs/subsystems/slots.md @@ -0,0 +1,171 @@ +# Web Client Slots + +English | [中文](slots.zh.md) + +Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots`](../../packages/client/ui-slots/README.md) defines the React-free registry and type algebra; [`dsh-client-ui-renderer`](../../packages/client/ui-renderer/README.md) binds observable sources to hooks, renders the tree, and owns React contexts internally. A feature plugin contributes UI through `ctx.slots.register()` and never imports another feature plugin's component. + +This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md). + +## Declaration and lifecycle + +`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location. + +Declaring a child has three effects: it makes the child key live, authorizes that parent entry's `renderSlot` or `renderSlotChain` call, and records the runtime dispatch specification. One live entry owns each declaration. Registering into an undeclared slot or declaring a child already owned elsewhere fails during plugin activation. + +`root` is the only built-in declaration and the only key rendered through the Cordis service itself. `ui-renderer` calls `ctx.slots.renderSlot('root', {})`; every descendant is rendered through the `renderSlot` or `renderSlotChain` prop of the entry that declared it. + +Registrations and declarations follow Cordis effect lifetimes. Disposing an entry removes its contribution and recursively collapses the child slots it declared. A feature that contributes into another package's slot therefore uses `ctx.slots.inject(key, callback)`: the callback runs for each declaration lifetime, its effects are removed when the owner collapses, and it runs again if the owner is mounted again. + +```tsx ignore-check +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' + +type HeaderActionProps = PropsRuntime<'conversation.session.header.actions'> + +function HeaderAction({ useSession }: HeaderActionProps) { + const running = useSession(snapshot => snapshot.running) + return +} + +export const inject = ['slots'] + +export function apply(ctx: Context): void { + ctx.slots.inject('conversation.session.header.actions', () => + ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'review', + order: 100, + }, HeaderAction)) +} +``` + +## Cardinality and scope + +The slot declaration fixes two independent axes. + +| Axis | Value | Meaning | +|---|---|---| +| cardinality | `single` | One cell. The active priority winner renders. Use a child slot instead of treating this as an additive list. | +| cardinality | `list` | Cells are addressed by required `id` and ordered by `order`, then registration order. | +| cardinality | `keyed` | The owner dispatches an `entryKey`; the matching cell renders with any key-specific props. | +| cardinality | `chain` | Each entry supplies a pure `select(owner)` function. The first non-null result in priority order renders and receives that result as `matched`; otherwise the owner fallback renders. | +| scope | `root` | One root-scoped component and store instance. | +| scope | `session-maybe` | Follows current selection but stays renderable without a Session; Session values are optional. | +| scope | `session` | Requires a resolved Session binding and receives definite Session values. | + +`priority` is a shadowing rank for `single`, `list`, and `keyed` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list `id` or keyed `key`; intentionally reusing a shipped cell replaces its presentation. + +## Component inputs + +A registered component receives inputs assembled at its binding site. Components derive these types rather than copying their members. + +| Input | Declared by | Component type | +|---|---|---| +| owner values and standard scope values | the `SlotMap` row and installed scope adapters | `PropsRuntime` | +| authorized child renderers | the registration's `children` keys | `PropsRenderSlots` | +| selector hook and mutation callbacks for shared view state | the registration's `store` | `PropsStore` | +| private data, callbacks, and observable hooks | the registration's `inject` factory | `InjectFace` | +| localized `t` function | the registration's `locale` namespace | `PropsLocale` | +| selected chain value | the registration's `select` result | `matched` through `ComposedProps` | + +`SessionProvider` is also present in `PropsRenderSlots` when an entry declares a strict Session child. It binds that subtree to the current Session identity and remounts the body when the identity changes. + +Components never receive `ctx`. Parent-owned point-in-time values enter through the owner argument to `renderSlot`; shared view state uses a declared store; services and model objects stay in the `apply` closure and are projected into callbacks or observable sources. + +## Framework-provided hooks + +The shipped adapters add these standard props. They are available according to the target slot's scope, independent of which package registered the component. + +| Availability | Props | Owner | +|---|---|---| +| every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` | +| every scope | `useWorkspaces` | `ui-workspace` | +| `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | +| `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | +| `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | +| `session-maybe` | optional `useConversation`, `useInput`, `inputActions` results | `ui-conversation` | +| `session` | `useChat` | `ui-chat` | +| `session` | `useTrajectory` | `ui-trajectory` | + +The renderer also creates `useStore` from a declared store and `t` from a declared locale namespace. These are registration-derived props rather than global standard props. + +Framework and domain-adapter owners may extend the standard set through `ctx.slots.provideRoot()` or `ctx.uiSession.provide()` together with the corresponding `GlobalStandardProps`, `SessionStandardProps`, or `SessionMaybeStandardProps` declaration merge. A feature component should not create a React hook prop itself or add a global standard prop for entry-private data. + +## Developer-provided injection + +The `inject` option on a registration is the ordinary feature-owned injection point. Its factory runs in the plugin's `apply` world, may close over injected Cordis services, and returns only the data and callbacks that the component needs. For a `session` slot it receives `sessionId`; for `session-maybe` it receives `sessionId | undefined`; when a store is declared it also receives the store's bound actions. + +A reserved `hooks` object in that return value accepts bare `getSnapshot`/`subscribe` sources. The renderer converts `hooks: { status }` into a `useStatus(selector)` component prop and caches the binding by source identity. Components do not receive the source itself and do not call `useSyncExternalStore` directly. + +The owner of a slot may put an `inject` face in the child declaration when every occupant needs the same capability. Plain members reach all occupants unchanged. Function-valued members inside its `hooks` object are hook factories; they receive the slot's standard props and optional per-render `hookContext`, then return the constrained hook exposed to the occupant. `conversation.chat.node` uses this mechanism to provide `useTurnData(key)` for the node currently being rendered. + +Use owner props for values already known at one render occurrence, registration `inject` for one entry's callbacks and private observables, slot-level `inject` for a capability controlled by the slot owner, and a declared store for mutable view state shared across entries or preserved across remounts. React nodes compose through child slots, not through injected values. + +## Current hierarchy + +The hierarchy below is the shipped declaration tree. A child exists only while the named parent entry is mounted; optional feature entries can therefore make a subtree appear or disappear as one lifecycle unit. + +```text +root +├─ sidebar +│ ├─ sidebar.brand.mark +│ ├─ sidebar.brand.name +│ ├─ sidebar.footer.action +│ ├─ sidebar.workspaces +│ │ └─ sidebar.workspaces.directoryFlow +│ └─ sidebar.settings +│ ├─ settings.trigger +│ ├─ settings.header +│ ├─ settings.action +│ ├─ settings.close +│ ├─ settings.onboarding +│ └─ settings.section +│ ├─ settings.general.item +│ └─ settings.plugins.tab +│ └─ settings.plugin.item +├─ conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ └─ tool.view.cordis +│ │ └─ conversation.message.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ └─ conversation.session.header.utilities +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset +├─ details +│ └─ conversation.details.tool +└─ shell.overlay +``` + +The generated Client inspect catalog is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. + +## Extension rules + +- Import another feature package only for declarations with `import type`; never import or re-export its runtime values. +- Declare a new child slot only in the component that owns and renders that location. Other packages wait with `ctx.slots.inject()` and contribute through `ctx.slots.register()`. +- Keep business and transport state in their owning Cordis services or Client models. Slot stores hold shared viewing and interaction state only. +- Keep observable source and snapshot identities stable between changes. Republish through the same source whenever its value changes. +- Pass JSON-compatible data and callbacks between UI domains. The `hooks` compartment is the sole exception for bare observables; React content travels through slots. +- Treat `single` and an occupied keyed cell as replacement points. Use list ids or an unoccupied key for additive extensions. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md new file mode 100644 index 0000000000..23277e94e2 --- /dev/null +++ b/docs/subsystems/slots.zh.md @@ -0,0 +1,171 @@ +# Web Client Slots + +[English](slots.md) | 中文 + +Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](../../packages/client/ui-slots/README.zh.md)定义不依赖 React 的注册表与类型代数;[`dsh-client-ui-renderer`](../../packages/client/ui-renderer/README.zh.md)把可观测源绑定成钩子、渲染整棵树,并在内部拥有 React context。功能插件通过 `ctx.slots.register()` 贡献 UI,绝不导入其他功能插件的组件。 + +本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。 + +## 声明与生命周期 + +`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。 + +声明一个 child 会同时产生三种效果:令该 child key 生效、授权 parent entry 调用 `renderSlot` 或 `renderSlotChain`,以及记录运行时 dispatch 规格。每个声明只能有一个存活 owner。向未声明 slot 注册,或重复声明其他 entry 已拥有的 child,都会在插件激活时失败。 + +`root` 是唯一内建声明,也是唯一由 Cordis service 自身渲染的 key。`ui-renderer` 调用 `ctx.slots.renderSlot('root', {})`;其余每个后代都通过声明它的 entry 所收到的 `renderSlot` 或 `renderSlotChain` prop 渲染。 + +注册和声明遵循 Cordis effect 生命周期。销毁一个 entry 会移除其贡献,并递归折叠它声明的 child slots。因此,向其他包的 slot 贡献功能时使用 `ctx.slots.inject(key, callback)`:callback 会在每段声明生命周期内运行,owner 折叠时其 effect 随之移除,owner 再次挂载时则重新运行。 + +```tsx ignore-check +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' + +type HeaderActionProps = PropsRuntime<'conversation.session.header.actions'> + +function HeaderAction({ useSession }: HeaderActionProps) { + const running = useSession(snapshot => snapshot.running) + return +} + +export const inject = ['slots'] + +export function apply(ctx: Context): void { + ctx.slots.inject('conversation.session.header.actions', () => + ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'review', + order: 100, + }, HeaderAction)) +} +``` + +## Cardinality 与 scope + +Slot 声明固定两个相互独立的维度。 + +| 维度 | 值 | 含义 | +|---|---|---| +| cardinality | `single` | 单个 cell,渲染当前 priority 胜者;需要并列内容时应声明 child slot,而不是把它当作列表。 | +| cardinality | `list` | cell 由必填 `id` 定址,先按 `order`、再按注册顺序排列。 | +| cardinality | `keyed` | owner 传入 `entryKey`;匹配 cell 以该 key 对应的 props 渲染。 | +| cardinality | `chain` | 每个 entry 提供纯 `select(owner)` 函数;按 priority 顺序遇到的第一个非 null 结果获选,并以 `matched` 传给组件;全部拒绝时渲染 owner fallback。 | +| scope | `root` | 一个 root 作用域组件和 store 实例。 | +| scope | `session-maybe` | 跟随当前选择,但没有 Session 时仍可渲染;Session 值是可选的。 | +| scope | `session` | 要求可解析的 Session binding,并收到确定存在的 Session 值。 | + +对于 `single`、`list` 和 `keyed` cell,`priority` 是遮蔽优先级;对于 `chain`,它是选举顺序。数值越小越先运行或渲染。普通增量贡献应选用新的 list `id` 或 keyed `key`;复用已有 cell 表示有意替换其展示。 + +## 组件输入 + +注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。 + +| 输入 | 声明者 | 组件类型 | +|---|---|---| +| owner 值与标准 scope 值 | `SlotMap` 条目与已安装的 scope adapter | `PropsRuntime` | +| 获授权的 child renderer | 注册项的 `children` keys | `PropsRenderSlots` | +| 共享视图状态的 selector hook 与 mutation callback | 注册项的 `store` | `PropsStore` | +| 私有数据、callback 与 observable hook | 注册项的 `inject` factory | `InjectFace` | +| 本地化 `t` 函数 | 注册项的 `locale` namespace | `PropsLocale` | +| chain 选中的值 | 注册项的 `select` 结果 | 通过 `ComposedProps` 提供的 `matched` | + +当 entry 声明 strict Session child 时,`PropsRenderSlots` 还会提供 `SessionProvider`。它把子树绑定到当前 Session identity,并在 identity 改变时重新挂载 body。 + +组件绝不会收到 `ctx`。父组件在某次渲染时已经知道的值通过 `renderSlot` 的 owner 参数进入;共享视图状态使用声明的 store;service 与 model object 留在 `apply` closure 中,只向组件投影 callback 或 observable source。 + +## 框架提供的 hooks + +当前组合中的 adapter 会添加以下标准 props。它们按目标 slot 的 scope 提供,与注册组件来自哪个包无关。 + +| 可用范围 | Props | Owner | +|---|---|---| +| 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` | +| 所有 scope | `useWorkspaces` | `ui-workspace` | +| `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | +| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | +| `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | +| `session-maybe` | 结果可选的 `useConversation`、`useInput`、`inputActions` | `ui-conversation` | +| `session` | `useChat` | `ui-chat` | +| `session` | `useTrajectory` | `ui-trajectory` | + +Renderer 还会根据声明的 store 创建 `useStore`,并根据声明的 locale namespace 创建 `t`。这些是由注册项推导的 props,不属于全局标准 props。 + +框架与领域 adapter owner 可以通过 `ctx.slots.provideRoot()` 或 `ctx.uiSession.provide()` 扩展标准集合,同时提供对应的 `GlobalStandardProps`、`SessionStandardProps` 或 `SessionMaybeStandardProps` 声明合并。普通功能组件不应自行创建 React hook prop,也不应为 entry 私有数据添加全局标准 prop。 + +## 开发者提供的 injection + +注册项的 `inject` 选项是通常使用的功能私有注入点。它的 factory 在插件的 `apply` 世界中运行,可以闭包捕获已经注入的 Cordis service,并且只返回组件所需的数据与 callback。对于 `session` slot,它会收到 `sessionId`;对于 `session-maybe`,它收到 `sessionId | undefined`;声明 store 后,它还会收到该 store 绑定后的 actions。 + +返回值中保留的 `hooks` 对象接收裸 `getSnapshot`/`subscribe` source。Renderer 把 `hooks: { status }` 转换为组件 prop `useStatus(selector)`,并按 source identity 缓存绑定。组件不会收到 source 本身,也不直接调用 `useSyncExternalStore`。 + +当每个 occupant 都需要同一种能力时,slot owner 可以在 child 声明里放置 `inject` face。普通成员会原样交给所有 occupant;其 `hooks` 对象中的函数成员是 hook factory,它会收到 slot 的标准 props 与可选的逐次渲染 `hookContext`,再返回提供给 occupant 的受限 hook。`conversation.chat.node` 正是通过这种机制,为当前渲染的 node 提供 `useTurnData(key)`。 + +一次渲染时 owner 已知的值走 owner props;单个 entry 的 callback 与私有 observable 走注册项 `inject`;由 slot owner 控制、所有 occupant 共享的能力走 slot 级 `inject`;需要跨 entry 共享或跨重新挂载保留的可变视图状态走声明的 store。React node 通过 child slot 组合,不通过注入值传递。 + +## 当前层级 + +下图是当前发布组合的声明树。只有具名 parent entry 已挂载时,其 child 才存在;因此可选功能 entry 可以作为一个生命周期单元让整棵子树出现或消失。 + +```text +root +├─ sidebar +│ ├─ sidebar.brand.mark +│ ├─ sidebar.brand.name +│ ├─ sidebar.footer.action +│ ├─ sidebar.workspaces +│ │ └─ sidebar.workspaces.directoryFlow +│ └─ sidebar.settings +│ ├─ settings.trigger +│ ├─ settings.header +│ ├─ settings.action +│ ├─ settings.close +│ ├─ settings.onboarding +│ └─ settings.section +│ ├─ settings.general.item +│ └─ settings.plugins.tab +│ └─ settings.plugin.item +├─ conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ └─ tool.view.cordis +│ │ └─ conversation.message.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ └─ conversation.session.header.utilities +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset +├─ details +│ └─ conversation.details.tool +└─ shell.overlay +``` + +生成的 Client inspect catalog 是每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 + +## 扩展规则 + +- 另一个功能包只能通过 `import type` 引入声明;绝不导入或转发它的运行时值。 +- 只在拥有并渲染某个位置的组件中声明新的 child slot。其他包通过 `ctx.slots.inject()` 等待,再通过 `ctx.slots.register()` 贡献内容。 +- 业务与传输状态留在所属 Cordis service 或 Client model 中。Slot store 只承载共享的视图与交互状态。 +- 可观测 source 及其 snapshot identity 在值变化前保持稳定;值变化时通过同一个 source 发布。 +- UI domain 之间只传 JSON 兼容数据和 callback。`hooks` compartment 是裸 observable 的唯一例外;React 内容通过 slot 传递。 +- 将 `single` 和已有 occupant 的 keyed cell 视为替换点。增量扩展使用 list id 或尚未占用的 key。 diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml new file mode 100644 index 0000000000..3c8e1c99ed --- /dev/null +++ b/docs/subsystems/web-client.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 docs/subsystems/web-client.md +web-client.md: 40902c273e2daafb5ea8acf2aefb0ce2a418b3f4 +web-client.zh.md: 79452e73c7ed6ed89df834995291c8f0e44d8258 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md new file mode 100644 index 0000000000..40902c273e --- /dev/null +++ b/docs/subsystems/web-client.md @@ -0,0 +1,95 @@ +# Web Client architecture + +English | [中文](web-client.zh.md) + +The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session event window into target-owned views. This page connects those systems and defines where Client models and feature packages belong. + +## Layers and ownership + +| Layer | Main owners | Responsibility | +|---|---|---| +| Host application | business services and `packages/api/*-controller` Host entries | Own authoritative state, persistence, mutation ordering, access policy, and stream production. | +| Transport and API assembly | `client/connection`, `api/gateway`, `api/remotes` | Establish a Client generation, expose generated `ctx.remote` methods and streams, forward selected Cordis events, and carry cancellation and results. | +| Client models | `api/session-controller/client`, `api/workspace-controller/client` | Maintain React-free mirrors of Host state, resolve stream/unary races, own object identities and subscriptions, and expose narrow command services. | +| UI adapters | `client/ui-session`, `client/ui-workspace` | Convert model observables into root or Session-scoped standard Slot sources without taking ownership of business state. | +| Conversation data | `client/ui-conversation`, target packages such as `ui-chat` and `ui-trajectory` | Assemble durable Session events into independent target snapshots and own the shared conversation shell and input flow. | +| Composition and rendering | `client/ui-slots`, `client/ui-renderer`, `client/ui-layout`, feature UI packages | Declare extension locations, derive component props, bind observables to React hooks, and mount the final tree. | + +The dependency direction is Host state → Remote transport → Client model → UI adapter → Conversation or presentation → Slots → React. User actions travel back through callbacks that close over an injected Client service or generated Remote namespace. A presentation component never receives Cordis `ctx`, a transport object, or another feature plugin's implementation. + +## Browser boot + +The Host writes the composed `WebBootGraph` to `window.__DSH_BOOT__` and installs the browser module-loader facade before parser-preloaded scripts execute. The module system is a lazy CommonJS table: loading a bundle registers its factory, while materializing an entry runs the factory with synchronous `require` over platform modules and declared dynamic dependencies. + +The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state, `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. + +## Remote communication + +Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. + +The Connection owns request correlation, the `/api` carrier, trust checks, Host description, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. API Proxy handles only `/api` endpoints that no strict Remote descriptor claims; new controller operations belong on generated Remote methods or explicit Remote streams. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. + +The internal `$events` logical stream is the Connection generation source. A generation becomes connected only after the event source emits `ready` and `host.describe` succeeds. Host listeners are therefore attached before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. + +## Client models + +Each API controller package owns a paired Host and Client face. The Host side owns authoritative mutation and stream production. The Client side owns an identity-stable, React-free model over the same generated wire types and exposes observable snapshots plus commands. UI packages consume these Client services and do not reproduce transport state in component stores. + +### Sessions + +[`api/session-controller`](../../packages/api/session-controller/README.md) exposes Host commands for list, search, creation, selection data, prompt, queue, cancellation, pagination, and follow/control streams. Its Client side is organized as `ClientSessions → SessionManager → Session`: + +- `ClientSessions` provides `ctx.sessions`, owns Session scopes and stable `SessionBinding` objects, and projects the selected list state. +- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates. +- Each `Session` owns one contiguous event window, paging, follow, prompt/control state, and the observable snapshot consumed by adapters. + +The durable event path opens `follow()` before reading the first page. A page establishes a contiguous window; live events append by sequence; older pages prepend without replacing unrelated objects. A gap or a new physical generation reads a fresh tail through the opening cursor before publishing a replacement. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates. + +### Workspaces + +[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns the browser rows, order, archived Session ids, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, and `archived` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks to the UI. + +This pairing is not a second source of business truth. Host controllers decide durable state and mutation outcomes; Client models maintain the latest usable local projection, preserve object identity where useful to rendering, and encode how delayed responses and replacement baselines merge. + +## Conversation and presentation + +`ui-session` installs the `session` scope adapter and publishes `useSessions`, `useSession`, `sessionId`, and `useProjection`. Domain adapters add further standard sources without putting React hooks on the model objects. + +`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates raw durable events into stable business Contexts, and its view registry materializes target snapshots. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers. + +`ui-slots` provides the typed registry and lifecycle ledger; `ui-renderer` is the only package that binds bare observables through `useSyncExternalStore`, owns React contexts, and renders the root tree. Feature components receive framework hooks, owner props, store actions, and explicit injection through their derived props. [Web Client Slots](slots.md) lists those inputs, extension APIs, and the current Slot hierarchy. + +## Data paths + +| Path | Sequence | +|---|---| +| durable Session display | Host Session log → Remote `follow` plus `page` → Client `Session` event window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React | +| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection stores → Session and list snapshots → standard hooks → components | +| Workspace state | Host Workspace baseline and increments → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar, hero, and navigation entries | +| scoped interaction | Host Cordis waterfall → API Remotes `$events` → `ctx.remote.$on()` on the Session Context → owning UI package → result or `next()` | +| user command | component callback → registration inject face or Slot owner → `ctx.sessions`, `ctx.workspaces`, or generated scoped Remote → Host Controller → authoritative update → stream or event projection back to the Client | + +## Reconnection + +Physical and logical recovery are separate. Gateway mux restores the physical WebSocket; each `RemoteStream` reopens its own logical source when the Connection publishes a usable generation. A carrier failure is retryable, while a business error, malformed opening item, or protocol violation is terminal for the owning logical stream. + +Recovery follows the data's semantics: + +- A durable Session journal resumes from the last accepted sequence and repairs the loaded window against a tail page before accepting later events. +- Session control and Workspace streams retain the last published value while disconnected, then atomically replace it from a fresh opening baseline. +- Ordinary forwarded notifications are not replayed. Stateful domains need a baseline, cursor, or explicit query; scoped waterfalls retain their own request lifetime. + +There is no monolithic Client `Runtime`, `HostFrame`, `events.mux`, `events.host`, or universal `resync()` API. The Connection exposes generation state, Gateway owns logical stream supervision, and each Client model defines replacement or resume semantics appropriate to its data. + +## Package boundaries + +Feature plugin packages may share declarations through `import type`; they do not runtime-import or re-export another feature plugin's values. Cross-package behavior uses injected Cordis services, and cross-package UI uses Slots. Target-specific Conversation Definitions, projection helpers, and final view data stay with their target package even when Chat and Trajectory intentionally implement parallel logic. + +Shared runtime values need a narrow static owner with no feature lifecycle, such as `client/store`, `ui-primitives`, or a browser-safe utility package. Transport and generated API assembly may import runtime contributions because assembling one protocol is their explicit responsibility. A feature package does not add `dsh.client.external` merely to bypass this rule. + +Use the four detailed references according to the extension being added: + +- [Client Modules](client-modules.md) for package discovery, loading, shared module identities, and boot order. +- [API Gateway](../api-gateway.md) for Host methods, generated Remote contributions, streams, and forwarded events. +- [Web Client Slots](slots.md) for components, hooks, stores, injection, and placement. +- [Conversation](conversation.md) for durable event correlation, target snapshots, and Chat or Trajectory view contributions. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md new file mode 100644 index 0000000000..79452e73c7 --- /dev/null +++ b/docs/subsystems/web-client.zh.md @@ -0,0 +1,95 @@ +# Web Client 架构 + +[English](web-client.md) | 中文 + +Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI,[Conversation](conversation.zh.md) 把 Session 事件窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。 + +## 分层与所有权 + +| 层 | 主要 owner | 职责 | +|---|---|---| +| Host 应用 | 业务 service 与 `packages/api/*-controller` Host entry | 拥有权威状态、持久化、mutation 顺序、访问策略与 stream 生产。 | +| 传输与 API assembly | `client/connection`、`api/gateway`、`api/remotes` | 建立 Client generation,公开生成的 `ctx.remote` method 与 stream,转发选定的 Cordis event,并承载取消和结果。 | +| Client model | `api/session-controller/client`、`api/workspace-controller/client` | 维护不依赖 React 的 Host 状态镜像,处理 stream/unary 竞态,拥有对象 identity 与订阅,并公开收窄的 command service。 | +| UI adapter | `client/ui-session`、`client/ui-workspace` | 把 model observable 转换为 root 或 Session scope 的标准 Slot source,不接管业务状态所有权。 | +| Conversation 数据 | `client/ui-conversation`、`ui-chat` 与 `ui-trajectory` 等 target package | 把持久 Session event 组装成相互独立的 target snapshot,并拥有共享的 Conversation shell 与输入流程。 | +| 组合与渲染 | `client/ui-slots`、`client/ui-renderer`、`client/ui-layout`、各 UI 功能包 | 声明扩展位置、推导组件 props、把 observable 绑定成 React hook,并挂载最终组件树。 | + +依赖方向是 Host 状态 → Remote 传输 → Client model → UI adapter → Conversation 或 presentation → Slots → React。用户操作通过 callback 反向进入注入的 Client service 或生成的 Remote namespace。Presentation component 绝不接收 Cordis `ctx`、transport object 或其他功能插件的实现。 + +## 浏览器启动 + +Host 把组合后的 `WebBootGraph` 写入 `window.__DSH_BOOT__`,并在 parser-preloaded script 执行前安装浏览器 module-loader facade。模块系统是一张 lazy CommonJS 表:加载 bundle 只注册 factory;materialize entry 时才以同步 `require` 运行 factory,并解析 platform module 和已声明的动态依赖。 + +Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态后,`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 + +## Remote 通信 + +Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 + +Connection 拥有 request correlation、`/api` carrier、trust check、Host description 与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。API Proxy 只处理没有被严格 Remote descriptor 认领的 `/api` endpoint;新的 controller 操作应进入生成的 Remote method 或显式 Remote stream。[API Gateway 参考](../api-gateway.zh.md)定义生成与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 + +内部 `$events` logical stream 是 Connection generation source。只有 event source 发出 `ready` 且 `host.describe` 成功后,一代 connection 才会进入 connected。Host listener 因而先于任何 controller baseline read 挂载。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 + +## Client models + +每个 API controller 包都拥有配对的 Host face 与 Client face。Host 侧拥有权威 mutation 与 stream 生产;Client 侧基于相同的生成 wire type 维护 identity 稳定、与 React 无关的 model,并公开 observable snapshot 与 command。UI 包消费这些 Client service,不在 component store 中复制 transport state。 + +### Sessions + +[`api/session-controller`](../../packages/api/session-controller/README.zh.md)公开 Session list、search、creation、selection data、prompt、queue、cancellation、pagination 及 follow/control stream 等 Host command。其 Client 侧按 `ClientSessions → SessionManager → Session` 组织: + +- `ClientSessions` 提供 `ctx.sessions`,拥有 Session scope 与稳定的 `SessionBinding` object,并投影选中的 list state。 +- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。 +- 每个 `Session` 拥有一段连续 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。 + +持久 event 路径会先打开 `follow()`,再读取第一页。page 建立连续窗口;实时 event 按 seq append;旧 page prepend 时不替换无关对象。遇到 gap 或新的物理 generation 时,模型先通过 opening cursor 读取新 tail,再发布 replacement。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。 + +### Workspaces + +[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。 + +这种配对不会产生第二份业务真相。Host controller 决定持久状态与 mutation outcome;Client model 维护最新可用的本地 projection,在有利于渲染时保持 object identity,并明确 delayed response 与 replacement baseline 的合并规则。 + +## Conversation 与 presentation + +`ui-session` 安装 `session` scope adapter,并提供 `useSessions`、`useSession`、`sessionId` 和 `useProjection`。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 + +`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把原始持久 event 关联成稳定的业务 Context,view registry 则 materialize target snapshot。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。 + +`ui-slots` 提供类型化 registry 与 lifecycle ledger;`ui-renderer` 是唯一通过 `useSyncExternalStore` 绑定裸 observable、拥有 React context 并渲染 root tree 的包。功能 component 通过推导出的 props 接收 framework hook、owner prop、store action 与显式 injection。[Web Client Slots](slots.zh.md)列出这些输入、扩展 API 与当前 Slot 层级。 + +## 数据通路 + +| 路径 | 顺序 | +|---|---| +| 持久 Session 展示 | Host Session log → Remote `follow` 加 `page` → Client `Session` event window → Conversation Context → target snapshot(`chat`、`trajectory` 或其他已注册 target)→ Slot view → React | +| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection store → Session 与 list snapshot → 标准 hook → component | +| Workspace 状态 | Host Workspace baseline 与 increment → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar、hero 与 navigation entry | +| scoped interaction | Host Cordis waterfall → API Remotes `$events` → Session Context 上的 `ctx.remote.$on()` → 所属 UI 包 → result 或 `next()` | +| 用户 command | component callback → 注册项 inject face 或 Slot owner → `ctx.sessions`、`ctx.workspaces` 或生成的 scoped Remote → Host Controller → 权威 update → stream 或 event projection 回到 Client | + +## 重连 + +物理恢复与逻辑恢复彼此独立。Gateway mux 恢复物理 WebSocket;Connection 发布可用 generation 后,每个 `RemoteStream` 分别重开自己的 logical source。Carrier failure 可以重试;business error、非法 opening item 或 protocol violation 会令所属 logical stream 终止。 + +恢复方式由数据语义决定: + +- 持久 Session journal 从最后接受的 seq 继续,并在接受后续 event 前依据 tail page 修复已加载窗口。 +- Session control 与 Workspace stream 在断开期间保留最后一次发布的值,再用新的 opening baseline 原子替换。 +- 普通 forwarded notification 不会 replay。需要可靠恢复的 stateful domain 必须提供 baseline、cursor 或显式 query;scoped waterfall 保留自身的 request lifetime。 + +架构中没有统一的 Client `Runtime`、`HostFrame`、`events.mux`、`events.host` 或通用 `resync()` API。Connection 公开 generation state,Gateway 管理 logical stream,Client model 则按自身数据定义 replacement 或 resume 语义。 + +## 包边界 + +功能插件包可以通过 `import type` 共享声明;不得运行时导入或转发另一个功能插件的值。跨包行为使用注入的 Cordis service,跨包 UI 使用 Slots。特定 target 的 Conversation Definition、projection helper 与最终 view data 留在所属 target 包中,即使 Chat 和 Trajectory 有意实现平行逻辑。 + +共享运行时值需要一个职责收窄、没有功能生命周期的静态 owner,例如 `client/store`、`ui-primitives` 或浏览器安全的 util 包。Transport 与生成 API assembly 可以导入运行时 contribution,因为组装同一个 protocol 正是它们的显式职责。功能包不能只为绕过此规则而添加 `dsh.client.external`。 + +根据所添加的扩展查阅四篇详细参考: + +- [Client Modules](client-modules.zh.md):package discovery、loading、共享 module identity 与 boot order。 +- [API Gateway](../api-gateway.zh.md):Host method、生成的 Remote contribution、stream 与 forwarded event。 +- [Web Client Slots](slots.zh.md):component、hook、store、injection 与 placement。 +- [Conversation](conversation.zh.md):持久 event correlation、target snapshot,以及 Chat 或 Trajectory view contribution。 diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index bdd8281d60..e0a1790bdf 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -18,7 +18,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7931aaf0-d192-407a-a751-397bc43fb399"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": []\n }\n}"}],"isError":false}],"role":"user","id":"cf2f25e5-8b65-40f2-9301-1635e7497242"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: CallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: CallId;\\n readonly rootCallId?: CallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: CallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'code' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"a3bf1339-afe7-4fcc-bbf4-015a9867c86c"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 2c8c52869a..fe79fe41f3 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"cordis_inspect_query","kind":"other","status":"in_progress","rawInput":{"platform":"host","provider":"Service","method":"listService","input":{"service":"tools"}}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": []\n }\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: CallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: CallId;\\n readonly rootCallId?: CallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: CallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'code' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/package.json b/package.json index abe9431a8f..cb5f33f4d9 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", "gen-client-catalog": "tsx scripts/gen-client-catalog.ts", "gen-cordis-inspect-catalog": "tsx scripts/gen-cordis-inspect-catalog.ts", + "verify-cordis-inspect-catalog": "tsx scripts/gen-cordis-inspect-catalog.ts --check", "verify-client-catalog": "tsx scripts/gen-client-catalog.ts --check", "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index a6d764bf4a..4079dd712a 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -99,7 +99,8 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-jobs": { "optional": true }, @@ -134,6 +135,7 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" } } diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 4e64574681..d2129e7dd6 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -76,7 +76,10 @@ export interface ISessions { noteAgentPreset(sessionId: SessionId, agentPreset: string): void /** Clear the current selection into the no-session view state. */ clear(): void - /** @returns completion of the current or newly started Session-list refresh. */ + /** + * Refresh the Host-authoritative Session list. + * @returns completion of the current or newly started Session-list refresh. + */ refresh(): Promise /** * Search the Host's visible message-content index. Results stay diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index acbc81cae3..86735be6ea 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-agent/types' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' -import type { ISessions } from './contract/sessions.ts' import type { SessionRemotes } from './sessions/remotes.ts' import type {} from '../remote-events.ts' @@ -26,7 +25,7 @@ export type { } from './transport.ts' export { createScope, scopeOf } from './scope.ts' export type { AgentContext, AgentScopeHandle } from './scope.ts' -export { SessionCreateError, SessionForkError, workspaceTitleOf } from './sessions/service.ts' +export { SessionCreateError, SessionForkError } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { SessionListPhase, @@ -52,18 +51,8 @@ export type { SessionSnapshot, } from './contract/snapshot.ts' export type { ClientFailure, ClientResult } from './contract/result.ts' -export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' -export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' declare module '@deepseek-ai/cordis' { - interface Events { - /** - * A Host connection generation completed its readiness handshake. - * @mode emit - */ - 'connection/reset'(): void - } - interface Context { /** Client Session object layer and Agent scope owner. */ sessions: import('./contract/sessions.ts').ISessions @@ -79,17 +68,6 @@ export const inject = [ 'remote.session', ] -/** - * Resolve the Client Session service from any Client Cordis context. - * @param ctx - Client root or Agent-scoped context. - * @returns the Client Session object layer. - */ -export function resolveClientSessions(ctx: Context): ISessions { - const sessions = ctx.get('sessions') - if (sessions === undefined) throw new Error('session-controller: Client sessions service unavailable') - return sessions -} - /** * Install Client Session state and its reconnecting control stream. * @param ctx - Client Cordis context. diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index d2594f600f..0194edef27 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -19,6 +19,7 @@ import type { IApiClient, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' import type { SessionJob as JobView } from '../../types.ts' @@ -146,20 +147,6 @@ export interface SessionBinding { // consumers keep their import site. export { scopeOf } from '../scope.ts' -/** - * Workspace display title of a session cwd: the path's last non-empty - * segment (both separators accepted; trailing separators ignored), or '' - * for separator-only paths — callers own their fallback (session id, raw - * cwd, default-directory copy). The repo-wide single basename derivation — - * every surface naming a workspace (picker rows, toggle labels, list titles) - * calls this instead of re-splitting paths. - * @param cwd - workspace directory path. - * @returns basename title, or '' when no non-empty segment exists. - */ -export function workspaceTitleOf(cwd: string): string { - return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' -} - /** * Display title projection: durable title, project directory basename, then * the raw id. diff --git a/packages/api/session-controller/src/client/sessions/subagent-lineage.ts b/packages/api/session-controller/src/client/sessions/subagent-lineage.ts deleted file mode 100644 index 14409cbe10..0000000000 --- a/packages/api/session-controller/src/client/sessions/subagent-lineage.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Pure subagent-lineage aggregation over the retained session-list mirror. - * Ordinary forks terminate propagation so each visible session owns only its - * uninterrupted subagent subtree. - * @module @deepseek-ai/dsh-api-session-controller/client/sessions/subagent-lineage - */ -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionSummary } from './service.ts' - -/** Descendant counts projected for one possible parent session. */ -export interface SubagentDescendantSummary { - /** All descendants connected through uninterrupted subagent-origin lineage. */ - readonly count: number - /** Descendants whose exact session summary is currently running. */ - readonly runningCount: number -} - -/** - * Index every subagent descendant under each ancestor it reaches through an - * uninterrupted subagent-origin chain. Cycles fail soft and orphan owners - * remain harmless map keys until their summaries arrive. - * @param summaries - retained session summaries keyed by id. - * @returns descendant totals and running totals keyed by possible parent id. - */ -export function indexSubagentDescendants( - summaries: Readonly>, -): ReadonlyMap { - const indexed = new Map() - for (const descendant of Object.values(summaries)) { - if (descendant.origin !== 'subagent') continue - const seen = new Set() - let current: SessionSummary | undefined = descendant - while (current?.origin === 'subagent' && current.parentId !== undefined - && !seen.has(current.id)) { - seen.add(current.id) - const aggregate = indexed.get(current.parentId) - if (aggregate === undefined) { - indexed.set(current.parentId, { - count: 1, - runningCount: descendant.running ? 1 : 0, - }) - } else { - aggregate.count += 1 - if (descendant.running) aggregate.runningCount += 1 - } - current = summaries[current.parentId] - } - } - return indexed -} diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 04508378f9..19cbf911f7 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -87,7 +87,7 @@ async function mount(initialHost?: HostDescription): Promise { ctx.reflect.provide('remote.session', remote.session) const fiber = ctx.plugin(SessionClient) await fiber - const sessions = SessionClient.resolveClientSessions(ctx) as ClientSessions + const sessions = ctx.sessions as ClientSessions return { ctx, api, @@ -108,14 +108,6 @@ async function flush(): Promise { } describe('Session Controller Client apply', () => { - it('requires the installed Session service at the resolver boundary', () => { - const ctx = new Context() - contexts.add(ctx) - - expect(() => SessionClient.resolveClientSessions(ctx)) - .toThrow('session-controller: Client sessions service unavailable') - }) - it('routes Session Remote Events and connection generations into the object layer', async () => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') diff --git a/packages/api/session-controller/tests/subagent-lineage.client.spec.ts b/packages/api/session-controller/tests/subagent-lineage.client.spec.ts deleted file mode 100644 index f9c8ec0d69..0000000000 --- a/packages/api/session-controller/tests/subagent-lineage.client.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionSummary } from '../src/client/index.ts' -import { indexSubagentDescendants } from '../src/client/index.ts' - -const sid = (id: string) => id as SessionId - -function summary( - id: string, - parentId?: SessionId, - origin?: 'subagent', - running = false, -): SessionSummary { - return { - id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0, - ...(parentId === undefined ? {} : { parentId }), - ...(origin === undefined ? {} : { origin }), - } -} - -function index(...summaries: SessionSummary[]) { - return indexSubagentDescendants(Object.fromEntries( - summaries.map(item => [item.id, item]), - )) -} - -describe('indexSubagentDescendants', () => { - it('counts every nested descendant and its exact running state', () => { - const owner = summary('owner') - const child = summary('child', owner.id, 'subagent') - const grandchild = summary('grandchild', child.id, 'subagent', true) - - const result = index(owner, child, grandchild) - expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 }) - expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 }) - }) - - it('stops at ordinary forks and fails soft on cycles and missing parents', () => { - const owner = summary('owner') - const child = summary('child', owner.id, 'subagent', true) - const fork = summary('fork', child.id) - const forkChild = summary('fork-child', fork.id, 'subagent', true) - const orphan = summary('orphan', sid('missing'), 'subagent', true) - const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent') - const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent') - - const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB) - expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 }) - expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 }) - expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 }) - expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 }) - expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 }) - }) -}) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 5703caf280..838fa6a03c 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -24,6 +24,7 @@ { "path": "../../core/tools" }, { "path": "../../util/brand" }, { "path": "../../util/crypto" }, + { "path": "../../util/workspace-path" }, { "path": "../../workspace/workspace" }, { "path": "../../typert/protocol" } ] diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 0ec758391e..0e2c3c5fa7 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -15,7 +15,6 @@ export { ClientWorkspaceModel } from './model.ts' export type { WorkspaceFollowSink, WorkspaceListPhase, WorkspaceRemote, WorkspaceSnapshot, } from './model.ts' -export { abbreviateHomePath, resolveWorkspacePath } from './path.ts' export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' export type { WorkspaceId, WorkspaceView } from '../types.ts' diff --git a/packages/api/workspace-controller/src/client/path.ts b/packages/api/workspace-controller/src/client/path.ts deleted file mode 100644 index 334c56b007..0000000000 --- a/packages/api/workspace-controller/src/client/path.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Resolve a workspace-relative path into the Host-facing spelling used by openPath. - * @param cwd - Session Workspace root, when known. - * @param path - absolute or Workspace-relative path. - * @returns an absolute path when a Workspace root is available, otherwise the original path. - */ -export function resolveWorkspacePath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || isWindowsStylePath(path)) return path - if (cwd === undefined || cwd === '') return path - const base = cwd.replace(/[/\\]+$/, '') - const rel = path.replace(/^[/\\]+/, '') - return `${base}/${rel}` -} - -/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */ -function isWindowsStylePath(value: string): boolean { - return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') -} - -/** - * Display-only POSIX home abbreviation. Windows drive and UNC paths stay - * verbatim, including when `home` itself is a Windows path. A missing, empty, - * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`. - * @param path - absolute or already-short display path. - * @param home - Host account home from `host.describe`; absent skips abbreviation. - * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. - */ -export function abbreviateHomePath(path: string, home?: string): string { - if (home === undefined || home === '') return path - if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path - const root = home.replace(/\/+$/, '') - if (root === '' || root === '/') return path - if (path.replace(/\/+$/, '') === root) return '~' - if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}` - return path -} diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 993ac55ca6..a8511ac61e 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -3,7 +3,8 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' -import type { WorkspaceId, WorkspaceView } from '../types.ts' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { WorkspaceView } from '../types.ts' import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' /** Structured create failure for callers that distinguish Host business errors. */ diff --git a/packages/api/workspace-controller/tests/path.client.spec.ts b/packages/api/workspace-controller/tests/path.client.spec.ts deleted file mode 100644 index 48a7d63b85..0000000000 --- a/packages/api/workspace-controller/tests/path.client.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/path.ts' - -describe('abbreviateHomePath', () => { - it('collapses a POSIX home and its descendants', () => { - expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') - expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') - expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project') - expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/') - }) - - it('keeps prefix-adjacent names and non-home paths', () => { - expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts') - expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts') - expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts') - expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already') - }) - - it('does not abbreviate when home is missing, empty, or the filesystem root', () => { - expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts') - expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts') - expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts') - expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts') - }) - - it('leaves Windows drive and UNC paths verbatim', () => { - expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project') - expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project') - expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project') - expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u') - }) -}) - -describe('resolveWorkspacePath', () => { - it('joins a relative path under cwd and passes absolute paths through', () => { - expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') - expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') - expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') - expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') - }) -}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json index e2f1eacd1c..46d392dff3 100644 --- a/packages/api/workspace-controller/tsconfig.client.json +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -8,7 +8,6 @@ "files": [ "src/client/index.ts", "src/client/model.ts", - "src/client/path.ts", "src/client/service.ts", "src/types.ts" ], diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 7508f1e591..2165e19c2a 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -1,12 +1,12 @@ # AGENTS.md — Web client stack -Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services). +Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Read the current [Web Client architecture](../../docs/subsystems/web-client.md), [Slots reference](../../docs/subsystems/slots.md), and [Conversation reference](../../docs/subsystems/conversation.md) before changing the corresponding layer. Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-`. ## Slot and props discipline -The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code: +The [Slots reference](../../docs/subsystems/slots.md) owns the current design; these are the rules you must not violate when writing or reviewing client code: 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `..` (e.g. `'tool.call.toolview'`). @@ -33,7 +33,7 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a 1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile. -3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. +3. **A feature plugin MUST NOT runtime-import or re-export another feature plugin's values, and MUST NOT declare `dsh.client.external` to obtain them.** Shared declarations use `import type`; behavior crosses packages through injected Cordis services, and UI crosses packages through slots. If neither fits, stop and escalate — do not add an export to unblock yourself. Shared runtime code belongs only in a narrow static owner such as `client/store`, `ui-primitives`, or a browser-safe utility package; transport and generated API assemblies keep their explicit infrastructure edges. ## ctx discipline (components never see ctx) @@ -41,7 +41,7 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a ## Layering red lines -The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): +The stack has one-way knowledge, documented in the [Web Client architecture](../../docs/subsystems/web-client.md): 1. **Data object layer** (React-free): `client/connection` owns transport generations, `api/session-controller/client` owns `ClientSessions` → `SessionManager` → `Session`, `api/workspace-controller/client` owns Workspace state, and `client/store` owns the snapshot-store engine (`defineStore`, `createSnapshotStore`, `shallowEqual`). Store products are bare observable sources with no hook members. 2. **Render machinery** (`ui-renderer`, dynamic plugin): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; production business code carries no ui-renderer value dependency. @@ -75,7 +75,7 @@ Client business code may statically read `process.env.DSH_CLIENT_*`; every refer A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. 1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, or `ui-slots` in package manifests. -2. **`dsh.client.external` adds a package-specific request.** Use it only for a non-baseline value import whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. +2. **`dsh.client.external` is not a feature-plugin dependency mechanism.** Only infrastructure, transport, or generated assembly may add a package-specific non-baseline value request whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. 3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. 5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles. @@ -98,7 +98,7 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent ## Conversation Node discipline -- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md). +- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation reference](../../docs/subsystems/conversation.md). - `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`. - The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks. @@ -144,7 +144,7 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com ## New component checklist -1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. +1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [Slots reference](../../docs/subsystems/slots.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. 3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. 4. Tokens only in CSS; product copy follows the localization rule above; English comments. diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5dedcee80b..8d6c16cfce 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -4,13 +4,13 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage, - isTokenDelta, } from '@deepseek-ai/dsh-llm/message' import { CallId, type MessageId } from '@deepseek-ai/dsh-llm/brand' import type { AssistantMessage, ContentBlock, MessageSource, + StreamChunk, TokenUsage, ToolResultMessage, UserMessage, @@ -40,6 +40,20 @@ import type { const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 +/* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */ +function isFixtureTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} +/* jscpd:ignore-end */ + interface FixtureSessionSummary { readonly sessionId: SessionId updatedAt: number @@ -1129,7 +1143,7 @@ function sessionStatsOf(log: readonly SessionEvent[]): { break case 'assistant/chunk': if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step - && openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) { + && openStep.firstTokenTime === null && isFixtureTokenDelta(event.data.chunk)) { openStep.firstTokenTime = event.time } break diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b3621f8c9..c21885f78e 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand|util-crypto)(\/|$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 649b855553..90f61b4893 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -31,10 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client", - "@deepseek-ai/dsh-client-ui-conversation/client" - ], "inject": [ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 0c12b119e7..f71a33008b 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -4,9 +4,21 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' -import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ApprovalKey } from '../locales.ts' +/* jscpd:ignore-start -- Approval and Question intentionally own independent pending-settlement lifecycles. */ +function settlePendingComposer(settle: () => void, failureMessage: string): Promise { + try { + settle() + return Promise.resolve() + } catch (error) { + return Promise.reject(error instanceof Error + ? error + : new Error(failureMessage, { cause: error })) + } +} +/* jscpd:ignore-end */ + declare module '@deepseek-ai/dsh-client-ui-session/client' { interface SessionPendingInteractionMap { /** Pending approval request. */ diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 4b5275de98..186afe1f55 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -31,10 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-workspace-controller/client", - "@deepseek-ai/dsh-client-ui-conversation/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", @@ -75,7 +71,9 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -103,6 +101,8 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 8b3dcd3e01..1e0730d740 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,9 +1,9 @@ /** Register the Chat Conversation target, renderers, stats, and details surface. */ import type { Context } from '@deepseek-ai/cordis' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' // Type-only service and declaration merges used by the apply world. import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx index e4c30bda40..8be42c0f42 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconBrowseOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' import type { ContextMessageNode } from '../contract/snapshot.ts' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 7c30c1b7db..c7368dc24d 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,7 +1,6 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.tsx b/packages/client/ui-chat/src/client/chat/StatsLine.tsx index be78b172c2..a2f9f6be60 100644 --- a/packages/client/ui-chat/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsLine.tsx @@ -3,7 +3,6 @@ // active conversation scrollport (see ConversationRoot data-conversation-scroll). import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { contextOccupancy } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -16,8 +15,6 @@ import { formatTokensPerSecond } from './message-chrome.ts' import { assistantStepReading } from '../contract/turn-metrics.ts' import css from './StatsLine.module.css' -export { contextOccupancy } - interface WindowStats { turns: number steps: number diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index b4f8605c4f..6f334d5e13 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -1,10 +1,8 @@ -import type { - ConversationLocation, ConversationViewNode, -} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantBlock, AssistantMessageNode, CommandNode, CompactionSummaryNode, - ModelRetryNode, RunningToolCall, ToolCallBlock, -} from './snapshot.ts' + ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall, + ToolCallBlock, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Final Chat render unit produced by a Chat business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 9a938cfa09..35b1562c5b 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -10,10 +10,6 @@ export type { ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -export { - emptyAssistantBlock, toAssistantBlock, toAssistantBlocks, -} from '@deepseek-ai/dsh-client-ui-conversation/client' - /** Stable live per-key reader for Chat nodes. */ export interface ChatNodeStore { /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 7eb11d7ab9..f0f2b9be88 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -2,14 +2,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type {} from '@deepseek-ai/dsh-llm-retry/types' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData } from '../contract/chat-nodes.ts' import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts' -import { toAssistantBlock, toAssistantBlocks } from '../contract/snapshot.ts' -import { emptyAssistantBlock } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import { + emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, +} from './event-projection.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index ce55ef45e9..f70e657014 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -9,7 +9,7 @@ import type { ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode, LegacyConversationSlice, PartialAssistant, RunningToolCall, } from '../contract/snapshot.ts' -import { sessionRecallLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { sessionRecallLabels } from './event-projection.ts' const EMPTY_KEYS: readonly string[] = [] const EMPTY_TURNS: readonly number[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts new file mode 100644 index 0000000000..0d909cdcdd --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts @@ -0,0 +1,170 @@ +/** Chat-owned conversion from durable Session events to Chat view data. */ + +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' +import type { + AssistantBlock, ContextProvenanceView, KnownContextForm, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/* jscpd:ignore-start -- Chat and Trajectory own independent event-to-view projections. */ + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function readString(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function collect(source: Record, member: string, field: string): string[] { + const list = source[member] + if (!Array.isArray(list)) return [] + const seen: string[] = [] + for (const entry of list) { + const record = asRecord(entry) + const value = record === null ? null : readString(record, field) + if (value !== null && !seen.includes(value)) seen.push(value) + } + return seen +} + +function joined(names: string[]): string | null { + return names.length > 0 ? names.join(', ') : null +} + +/** Forms Chat presents structurally; unknown merge-extensible values remain opaque. */ +const KNOWN_FORMS: readonly KnownContextForm[] = [ + 'instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall', +] + +/** + * Read the target-supported presentation form from a durable message source. + * @param source - Logged `user/message` source. + * @returns Supported form, or null for the opaque presentation. + */ +export function contextForm(source: unknown): KnownContextForm | null { + const record = asRecord(source) + const form = record === null ? null : readString(record, 'form') + return form !== null && (KNOWN_FORMS as readonly string[]).includes(form) + ? form as KnownContextForm + : null +} + +/** + * Project a durable message source to the Chat row's role and producer label. + * @param source - Logged `user/message` source. + * @returns Role and label rendered by Chat. + */ +export function contextProvenance(source: unknown): ContextProvenanceView { + const record = asRecord(source) + const kind = record === null ? null : readString(record, 'kind') + if (record === null || kind === null) return { role: 'inject', label: null } + switch (kind) { + case 'session-reference': + return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind } + case 'agent-instructions': + return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } + case 'plugin': + return { role: 'inject', label: readString(record, 'plugin') ?? kind } + case 'skill-invocation': + return { role: 'inject', label: readString(record, 'name') ?? kind } + default: + // MessageSourceMap is merge-extensible; keep an unknown producer + // visible by its durable kind. + return { role: 'inject', label: kind } + } +} + +/** + * Read distinct labels cited by a durable cross-session recall source. + * @param source - Logged `user/message` source. + * @returns Labels in first-seen order. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + +/** + * Classify finalized Assistant content for Chat rendering. + * @param content - Core content blocks. + * @returns Chat blocks in source order. + */ +export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] { + return content.map(toAssistantBlock) +} + +/** + * Classify one finalized Assistant block for Chat rendering. + * @param block - Core content block. + * @returns Chat block. + */ +export function toAssistantBlock(block: ContentBlock): AssistantBlock { + switch (block.type) { + case 'text': return { kind: 'text', text: block.text } + case 'reasoning': return { kind: 'reasoning', text: block.text } + case 'image': return { kind: 'image', attachment: block.attachment } + case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments } + default: return { kind: 'other', block } + } +} + +/** + * Create the initial Chat block for one streamed Assistant block kind. + * @param blockType - Wire block kind. + * @returns Empty block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { + switch (blockType) { + case 'text': return { kind: 'text', text: '' } + case 'reasoning': return { kind: 'reasoning', text: '' } + case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } + default: return { kind: 'other', block: null } + } +} + +/** Display-safe failure fields retained by Chat projections. */ +export interface DisplayFailure { + readonly code?: string + readonly message: string +} + +/** + * Convert a durable failure to locale-independent fields safe for Chat. + * @param failure - Failure preserved by a Session event. + * @returns Sanitized message and optional stable provider code. + */ +export function displayFailure(failure: unknown): DisplayFailure { + if (failure === null || typeof failure !== 'object') return { message: String(failure) } + const record = failure as { code?: unknown; message?: unknown } + const code = typeof record.code === 'string' ? record.code : undefined + // Provider AUTH messages may echo a masked or partially preserved credential. + // Keep the raw diagnostic in the Session log, but never retain it in UI state. + if (code === 'AUTH') return { code, message: '' } + return { + ...(code === undefined ? {} : { code }), + message: typeof record.message === 'string' ? record.message : JSON.stringify(failure), + } +} + +/** + * Whether a stream chunk carries visible model output for Chat timing. + * @param chunk - Stream chunk to inspect. + * @returns true for a non-empty text, reasoning, or Tool-call delta. + */ +export function isTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +/* jscpd:ignore-end */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index d8a09ddd53..9611a36cf8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -2,9 +2,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts' -import { contextForm, contextProvenance } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' +import { contextForm, contextProvenance } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { /** Labels cited by the immediately following session-reference context. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index cc94609bb3..ea52a3a565 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,6 +1,6 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' -import { emptyAssistantBlock, toAssistantBlock } from '../contract/snapshot.ts' +import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts' /** * Whether a stream chunk changes the partial assistant projection shown by the UI. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index f4b0695f0b..e3b41095ce 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -3,8 +3,8 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TurnErrorNode } from '../contract/snapshot.ts' -import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client' import { chatNode } from './common.ts' +import { displayFailure } from './event-projection.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index 23d3779e0f..9d2986484b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -7,9 +7,9 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' -import { toAssistantBlocks } from '../contract/snapshot.ts' import { deriveTurnMetrics } from '../contract/turn-metrics.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import { toAssistantBlocks } from './event-projection.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { diff --git a/packages/client/ui-chat/src/client/details/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts index 690cea34bc..9e2488b68f 100644 --- a/packages/client/ui-chat/src/client/details/tool-node-reader.ts +++ b/packages/client/ui-chat/src/client/details/tool-node-reader.ts @@ -1,4 +1,3 @@ -import { conversationContextKey } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' @@ -6,19 +5,6 @@ function toolNode(node: ReturnType): ChatNode<'tool-call'> return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined } -/** - * Read one root Tool lifecycle through the internal Chat Node index. - * @param snapshot - current Conversation snapshot. - * @param rootCallId - root call identity and Tool Context identity. - * @returns root lifecycle when it is materialized in the current window. - */ -export function rootToolCall( - snapshot: ChatSnapshot, - rootCallId: string, -): ToolCallBlock | undefined { - return toolNode(snapshot.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root -} - /** * Find any root or nested Tool lifecycle through the internal Node store. * @param snapshot - current Conversation snapshot. diff --git a/packages/client/ui-chat/src/client/historical-images.ts b/packages/client/ui-chat/src/client/historical-images.ts index 6203d4343e..1e53e78bb8 100644 --- a/packages/client/ui-chat/src/client/historical-images.ts +++ b/packages/client/ui-chat/src/client/historical-images.ts @@ -2,8 +2,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' -import { bytesToBase64 } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' interface ImageUrlEntry { readonly sessionId: SessionId diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 1816fae928..a847b039fd 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -41,10 +41,7 @@ export type { } from '@deepseek-ai/dsh-client-ui-conversation/client' export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts' -export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts' -export { - contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta, -} from '@deepseek-ai/dsh-client-ui-conversation/client' +export { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' /** Public merge surface for Chat renderer payloads contributed by other plugins. */ export interface ChatNodeDataMap {} diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 529e7d9f00..1600ccfdef 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -9,7 +9,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' 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 { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { en, zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -262,24 +262,6 @@ describe('StatsLine', () => { .toBe('Cache hit 90%| Input 100 tok · Output 5 tok') }) - it('computes context occupancy only when both a numerator and capacity are known', () => { - // The projected figure wins: it is the provider sample carried forward over - // the surface's movement, so a compaction shows without waiting a request. - expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 })) - .toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 }) - // A log whose projection predates the field still reads its bare sample. - expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 })) - .toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 }) - // A numerator without capacity has no denominator; capacity without a - // provider sample has no numerator yet, rather than a synthetic 0%. - expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull() - expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull() - expect(contextOccupancy(undefined)).toBeNull() - // Capacity and the sample are independent last-wins fields, so a model - // switch can pair a smaller new window with the previous route's prompt. - expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100) - }) - it('drops every token group when no projection is composed', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render() diff --git a/packages/client/ui-chat/tests/conversation.client.spec.ts b/packages/client/ui-chat/tests/conversation.client.spec.ts index 4664b92879..b7b411a40d 100644 --- a/packages/client/ui-chat/tests/conversation.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation.client.spec.ts @@ -1,9 +1,12 @@ -/** Assistant block classifier (moved here with sessions/conversation.ts). */ +/** Chat-owned event-to-view projection. */ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client' -import { toAssistantBlock, toAssistantBlocks } from '../src/client/contract/snapshot.ts' +import { + displayFailure, emptyAssistantBlock, toAssistantBlock, toAssistantBlocks, + isTokenDelta, +} from '../src/client/conversation-nodes/event-projection.ts' describe('toAssistantBlock', () => { it('classifies the four block shapes', () => { @@ -27,5 +30,30 @@ describe('toAssistantBlock', () => { { kind: 'image', attachment }, ]) expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' }) + expect(toAssistantBlock({ type: 'future' } as unknown as ContentBlock)) + .toEqual({ kind: 'other', block: { type: 'future' } }) + }) + + it('creates empty streamed block projections', () => { + expect(emptyAssistantBlock('text')).toEqual({ kind: 'text', text: '' }) + expect(emptyAssistantBlock('reasoning')).toEqual({ kind: 'reasoning', text: '' }) + expect(emptyAssistantBlock('tool-call')).toEqual({ kind: 'tool-call', callId: '', name: '', argsRaw: '' }) + expect(emptyAssistantBlock('future')).toEqual({ kind: 'other', block: null }) + }) + + it('redacts auth failures and presents the remaining durable values', () => { + expect(displayFailure({ code: 'AUTH', message: 'secret' })).toEqual({ code: 'AUTH', message: '' }) + expect(displayFailure({ code: 'TRANSPORT', message: 'offline' })) + .toEqual({ code: 'TRANSPORT', message: 'offline' }) + expect(displayFailure({ code: 'UNKNOWN' })).toEqual({ code: 'UNKNOWN', message: '{"code":"UNKNOWN"}' }) + expect(displayFailure(null)).toEqual({ message: 'null' }) + }) + + it('recognizes only non-empty token deltas', () => { + expect(isTokenDelta({ type: 'text-delta', index: 0, text: 'x' } as never)).toBe(true) + expect(isTokenDelta({ type: 'reasoning-delta', index: 0, text: '' } as never)).toBe(false) + expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '', name: 'tool' } as never)).toBe(true) + expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' } as never)).toBe(false) + expect(isTokenDelta({ type: 'finish', reason: 'stop' } as never)).toBe(false) }) }) diff --git a/packages/client/ui-conversation/tests/context-provenance.client.spec.ts b/packages/client/ui-chat/tests/event-projection.client.spec.ts similarity index 95% rename from packages/client/ui-conversation/tests/context-provenance.client.spec.ts rename to packages/client/ui-chat/tests/event-projection.client.spec.ts index 3259aac23e..aa333c4cba 100644 Binary files a/packages/client/ui-conversation/tests/context-provenance.client.spec.ts and b/packages/client/ui-chat/tests/event-projection.client.spec.ts differ diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index c551d4cfc1..6f37d011f3 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,6 +50,12 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../util/crypto" + }, + { + "path": "../../util/workspace-path" + }, { "path": "../../session/session-stats" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 876a3dbd9d..e80dd8e04f 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", @@ -80,7 +77,8 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -112,6 +110,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-conversation/src/client/browser-bytes.ts b/packages/client/ui-conversation/src/client/browser-bytes.ts deleted file mode 100644 index 90d881f1c6..0000000000 --- a/packages/client/ui-conversation/src/client/browser-bytes.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Encode bytes as canonical browser base64 without overflowing argument limits. - * @param data - bytes to encode. - * @returns base64 text. - */ -export function bytesToBase64(data: Uint8Array): string { - let binary = '' - const chunk = 0x8000 - for (let offset = 0; offset < data.length; offset += chunk) { - binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) - } - return btoa(binary) -} diff --git a/packages/client/ui-conversation/src/client/contract/context-provenance.ts b/packages/client/ui-conversation/src/client/contract/context-provenance.ts index 8fb154e558..db732bf59c 100644 --- a/packages/client/ui-conversation/src/client/contract/context-provenance.ts +++ b/packages/client/ui-conversation/src/client/contract/context-provenance.ts @@ -1,8 +1,4 @@ -// Conversation context source projection: the role and the human-facing producer name -// of one logged non-user `user/message`, read from its durable `source` alone. -// The client keeps no table of known plugin ids — a renamed or newly mounted -// producer must never need a client release to stay identifiable, and a resumed -// or foreign log must project the same way as a live one. +/** Shared types for target-owned context-source projections. */ /** * Which model-facing role a logged non-user message plays. @@ -27,106 +23,9 @@ export interface ContextProvenanceView { label: string | null } -/** One durable source narrowed to the readable-record shape; null for anything else. */ -function asRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : null -} - -/** A record field read as a non-empty string, or null. */ -function readString(record: Record, key: string): string | null { - const value = record[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */ -function collect(source: Record, member: string, field: string): string[] { - const list = source[member] - if (!Array.isArray(list)) return [] - const seen: string[] = [] - for (const entry of list) { - const record = asRecord(entry) - const value = record === null ? null : readString(record, field) - if (value !== null && !seen.includes(value)) seen.push(value) - } - return seen -} - -/** A collected name list rendered as one label; null when the list is empty. */ -function joined(names: string[]): string | null { - return names.length > 0 ? names.join(', ') : null -} - /** - * The referenced-session labels of one durable `session-reference` recall - * source, in first-seen order; empty for every other source shape, including - * a foreign or older log whose reference entries carry no readable label. - * @param source - the logged `user/message` source, exactly as recorded. - * @returns distinct non-empty reference labels. + * One durable context form this UI version knows how to present. Target + * projections map absent or unknown forms to their opaque presentation so + * logs written by older, newer, or foreign producers remain visible. */ -export function sessionRecallLabels(source: unknown): string[] { - const record = asRecord(source) - if (record === null || readString(record, 'kind') !== 'session-reference') return [] - return collect(record, 'references', 'label') -} - -/** - * Project one durable message source onto its transcript role and producer name. - * - * The source arrives over the wire as opaque JSON (`MessageSource` is - * merge-extensible, so no client-side union can be exhaustive), and a durable - * log may predate or postdate this UI; every unreadable shape therefore - * degrades to `inject` with whatever name the record still carries. - * @param source - the logged `user/message` source, exactly as recorded. - * @returns the role and producer name to present for this context. - */ -export function contextProvenance(source: unknown): ContextProvenanceView { - const record = asRecord(source) - const kind = record === null ? null : readString(record, 'kind') - if (record === null || kind === null) return { role: 'inject', label: null } - switch (kind) { - // Cross-session snapshots are the one durable source that carries another - // session's material; its references name the sessions they were read from. - case 'session-reference': - return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind } - // Workspace instructions name the files they were reconciled from, which - // identifies the producer far better than the plugin id would. - case 'agent-instructions': - return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } - case 'plugin': - return { role: 'inject', label: readString(record, 'plugin') ?? kind } - // A user-explicit skill invocation names the skill it injected. - case 'skill-invocation': - return { role: 'inject', label: readString(record, 'name') ?? kind } - // Documented default arm of the merge-extensible source map: an unknown - // producer still identifies itself by its own durable kind. - default: - return { role: 'inject', label: kind } - } -} - -/** - * Context forms this UI version renders with a dedicated presentation. The - * durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an - * unrecognized or absent value degrades to the opaque presentation rather than - * dropping the row, so a log written by a newer or foreign producer still - * renders. - */ -const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const - -/** One durable context form this UI version knows how to present. */ -export type KnownContextForm = typeof KNOWN_FORMS[number] - -/** - * Read the producer-declared form off one durable message source. - * @param source - the logged `user/message` source, exactly as recorded. - * @returns the form when this UI version presents it, otherwise null (opaque). - */ -export function contextForm(source: unknown): KnownContextForm | null { - const record = asRecord(source) - const form = record === null ? null : readString(record, 'form') - return form !== null && (KNOWN_FORMS as readonly string[]).includes(form) - ? form as KnownContextForm - : null -} +export type KnownContextForm = 'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall' diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index e3ad336313..7d50fa8124 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -133,13 +133,29 @@ export interface InputTriggerController { declare module '@deepseek-ai/cordis' { interface Events { - /** @param request - command claim and span. @mode bail */ + /** + * Claim a command token for the scoped input machine. + * @param request - command claim and span. + * @mode bail + */ 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined - /** @param request - reference and span. @mode bail */ + /** + * Insert a structured reference into the scoped input machine. + * @param request - reference and span. + * @mode bail + */ 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined - /** @param request - token guard. @mode bail */ + /** + * Consume a trigger token without inserting replacement content. + * @param request - token guard. + * @mode bail + */ 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined - /** @param request - plain text and span. @mode bail */ + /** + * Insert plain text into the scoped input machine. + * @param request - plain text and span. + * @mode bail + */ 'slash/input-insert-text'(request: InsertTextRequest): true | undefined } } diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index a92e284ca8..a03ac77ce7 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -32,8 +32,7 @@ export interface AssistantProvenanceView { model: string } -/** Assistant content blocks sorted by what the UI cares about - * (text body / collapsible reasoning / tool-call card head / other fallback). */ +/** Assistant content blocks sorted by what a UI target presents. */ export type AssistantBlock = | { kind: 'text'; text: string } | { kind: 'reasoning'; text: string } @@ -41,44 +40,6 @@ export type AssistantBlock = | { kind: 'tool-call'; callId: string; name: string; argsRaw: string } | { kind: 'other'; block: unknown } -/** - * core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end). - * @param content - core content blocks verbatim. - * @returns UI-classified blocks in source order. - */ -export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] { - return content.map(toAssistantBlock) -} - -/** - * Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw). - * @param block - one core content block. - * @returns the UI classification. - */ -export function toAssistantBlock(block: ContentBlock): AssistantBlock { - switch (block.type) { - case 'text': return { kind: 'text', text: block.text } - case 'reasoning': return { kind: 'reasoning', text: block.text } - case 'image': return { kind: 'image', attachment: block.attachment } - case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments } - default: return { kind: 'other', block } - } -} - -/** - * Create the empty projection for one streamed Assistant block kind. - * @param blockType - wire block kind. - * @returns empty projected block ready to receive deltas. - */ -export function emptyAssistantBlock(blockType: string): AssistantBlock { - switch (blockType) { - case 'text': return { kind: 'text', text: '' } - case 'reasoning': return { kind: 'reasoning', text: '' } - case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } - default: return { kind: 'other', block: null } - } -} - /** A finalized user message. */ export interface UserMessageNode { kind: 'user' @@ -145,9 +106,9 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown - /** Role and producer name projected from `source` ({@link contextProvenance}). */ + /** Role and producer name projected from `source` by the target. */ provenance: ContextProvenanceView - /** Producer-declared information form ({@link contextForm}); null presents as opaque. */ + /** Producer-declared information form supported by the target; null presents as opaque. */ form: KnownContextForm | null } diff --git a/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts b/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts deleted file mode 100644 index cf6e0d91e8..0000000000 --- a/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Shared assistant step-timing fold: target Definitions and Trajectory -// history fold derive AssistantTiming from the same step/start -> first token -// delta -> assistant/message sequence. - -import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { AssistantTiming } from '../contract/records.ts' - -// The first-token predicate lives beside the StreamChunk type in dsh-llm; -// re-exported here for consumers sharing the Conversation timing fold. -export { isTokenDelta } from '@deepseek-ai/dsh-llm/message' - -/** Pre-finalize timing boundaries for one assistant step (start + first token). */ -export interface AssistantStepMetadata { - stepStartTime: number | null - firstTokenTime: number | null -} - -/** - * Composite map key for one assistant step. - * @param turn - turn number from the event payload. - * @param step - step number from the event payload. - * @returns collision-free `turn`/`step` key (NUL separator). - */ -export function assistantStepKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -/** - * Fold one event into the per-step timing index: step/start opens the entry, - * the first non-empty token delta stamps first-token time once. Other event - * types are no-ops. - * @param steps - the mutable per-step index, keyed by {@link assistantStepKey}. - * @param event - the raw window event. - */ -export function indexAssistantStepTiming(steps: Map, event: SessionEvent): void { - if (event.type === 'step/start') { - steps.set( - assistantStepKey(event.data.turn, event.data.step), - { stepStartTime: event.time, firstTokenTime: null }, - ) - } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { - const key = assistantStepKey(event.data.turn, event.data.step) - const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null } - if (current.firstTokenTime === null) { - steps.set(key, { ...current, firstTokenTime: event.time }) - } - } -} - -/** - * Settle one finalized assistant message's timing from its step entry; a step - * whose start or first token fell outside the window yields null boundaries. - * @param steps - the per-step index built by {@link indexAssistantStepTiming}. - * @param turn - the assistant/message turn number. - * @param step - the assistant/message step number. - * @param completedTime - the assistant/message event timestamp (epoch ms). - * @returns the node-ready timing record. - */ -export function settledAssistantTiming( - steps: ReadonlyMap, - turn: number, - step: number, - completedTime: number, -): AssistantTiming { - return { - ...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }), - completedTime, - } -} diff --git a/packages/client/ui-conversation/src/client/conversation/failure-display.ts b/packages/client/ui-conversation/src/client/conversation/failure-display.ts deleted file mode 100644 index 85fdf89896..0000000000 --- a/packages/client/ui-conversation/src/client/conversation/failure-display.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Display-safe failure fields retained by locale-independent projections. */ -export interface DisplayFailure { - /** Stable provider failure code used for localized known-error copy. */ - code?: string - /** Sanitized provider message; empty when the code owns the display copy. */ - message: string -} - -/** - * Convert a durable failure into locale-independent fields safe for GUI projections. - * @param failure - Failure value preserved by the session event. - * @returns Sanitized message and optional stable provider code. - */ -export function displayFailure(failure: unknown): DisplayFailure { - if (failure === null || typeof failure !== 'object') return { message: String(failure) } - const record = failure as { code?: unknown; message?: unknown } - const code = typeof record.code === 'string' ? record.code : undefined - // Provider AUTH messages may echo a masked or partially preserved credential. - // Keep the raw diagnostic in the session log, but never project it into UI state. - if (code === 'AUTH') return { code, message: '' } - return { - ...(code === undefined ? {} : { code }), - message: typeof record.message === 'string' ? record.message : JSON.stringify(failure), - } -} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 33474321f1..0d6874f5a3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -4,14 +4,6 @@ export { UiConversation } from './conversation/assembly.ts' export type { ConversationBinding } from './conversation/assembly.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' export type { IConversation } from './service.ts' -export { bytesToBase64 } from './browser-bytes.ts' -export { settlePendingComposer } from './pending-composer.ts' -export { contextOccupancy } from './context-occupancy.ts' -export type { ContextOccupancy } from './context-occupancy.ts' -export { ReferenceIcon } from './skeleton/ReferenceIcon.tsx' -export type { ReferenceIconKind, ReferenceIconProps } from './skeleton/ReferenceIcon.tsx' - -export { conversationContextKey } from './contract/conversation.ts' export type { ConversationContextReader, ConversationEventInput, ConversationLocation, ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore, @@ -32,24 +24,12 @@ export type { ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, } from './contract/records.ts' -export { - emptyAssistantBlock, toAssistantBlock, toAssistantBlocks, -} from './contract/records.ts' export type { ContextProvenanceView, ContextRole, KnownContextForm, } from './contract/context-provenance.ts' -export { - contextForm, contextProvenance, sessionRecallLabels, -} from './contract/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './contract/request-inspection.ts' -export type { AssistantStepMetadata } from './conversation/assistant-timing.ts' -export { - assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming, -} from './conversation/assistant-timing.ts' -export { displayFailure } from './conversation/failure-display.ts' -export type { DisplayFailure } from './conversation/failure-display.ts' export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts' export { ConversationNodeAssembler } from './conversation/assembler.ts' diff --git a/packages/client/ui-conversation/src/client/pending-composer.ts b/packages/client/ui-conversation/src/client/pending-composer.ts deleted file mode 100644 index bb8be56766..0000000000 --- a/packages/client/ui-conversation/src/client/pending-composer.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Shared settlement mechanics for composer takeovers backed by a pending waterfall. */ - -/** - * Run one pending composer settlement and preserve non-Error rejection causes. - * @param settle - synchronous Promise resolver or rejector invocation. - * @param failureMessage - message used when the resolver throws a non-Error value. - * @returns completion or a rejection carrying the original failure. - */ -export function settlePendingComposer(settle: () => void, failureMessage: string): Promise { - try { - settle() - return Promise.resolve() - } catch (error) { - return Promise.reject(error instanceof Error - ? error - : new Error(failureMessage, { cause: error })) - } -} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 24c0303b6a..6b7d94829f 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -9,7 +9,7 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. @@ -23,7 +23,6 @@ import type { DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, } from './contract/input.ts' import type { InputSubmitMode } from './contract/composer-submission.ts' -import { bytesToBase64 } from './browser-bytes.ts' /** * The outward conversation face (`ctx.conversation`): the scope-addressed diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 2fdfe5e304..519b1b95a4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -6,7 +6,7 @@ import type { ReactNode, RefObject } from 'react' import { FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' -import { workspaceTitleOf } from '@deepseek-ai/dsh-api-session-controller/client' +import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ConversationSlotProps } from '../contract/slots.ts' import css from './HeroShell.module.css' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index d22049b370..8bedb2d260 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPlusOutline16, IconWarningOutline16, ReferenceIcon, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -24,7 +24,6 @@ import { deriveDecorations } from './decorations.ts' import type { DraftDecorations } from './decorations.ts' import type { EditRange } from '../contract/input.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' -import { ReferenceIcon } from './ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' diff --git a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx index 83499cd6d3..75b9a59d0c 100644 --- a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx +++ b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts' import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx' +import { contextOccupancy } from '../src/client/context-occupancy.ts' import css from '../src/client/skeleton/ContextMeter.module.css' import { en, zh } from '../src/client/locales.ts' @@ -27,6 +28,17 @@ function meter(values: Record, translate: ContextMeterProps['t' } describe('ContextMeter', () => { + it('computes occupancy only when both a numerator and capacity are known', () => { + expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 })) + .toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 }) + expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 })) + .toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 }) + expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull() + expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull() + expect(contextOccupancy(undefined)).toBeNull() + expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100) + }) + it('renders nothing until both pressure and capacity are known', () => { expect(meter({}).container.textContent).toBe('') expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('') diff --git a/packages/client/ui-conversation/tests/failure-display.client.spec.ts b/packages/client/ui-conversation/tests/failure-display.client.spec.ts deleted file mode 100644 index 0db2ae6834..0000000000 --- a/packages/client/ui-conversation/tests/failure-display.client.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { displayFailure } from '../src/client/conversation/failure-display.ts' - -describe('displayFailure', () => { - it('keeps ordinary diagnostics and stable provider codes', () => { - expect(displayFailure(null)).toEqual({ message: 'null' }) - expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' }) - expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({ - code: 'RATE_LIMIT', - message: 'try later', - }) - expect(displayFailure({ detail: 'unknown' })).toEqual({ - message: '{"detail":"unknown"}', - }) - }) - - it('removes the provider message when AUTH owns localized display copy', () => { - expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({ - code: 'AUTH', - message: '', - }) - }) -}) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index a851104426..020f0568c2 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -88,6 +88,9 @@ }, { "path": "../../util/crypto" + }, + { + "path": "../../util/workspace-path" } ], "exclude": [ diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 1bd81078ab..9c134a13ff 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-client-ui-workspace/client" - ], "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-ui-renderer", diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index 3266bd88af..571ab4f2a9 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -41,7 +41,6 @@ import { IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' -import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' @@ -49,9 +48,18 @@ import css from './DirectoryBrowser.module.css' export interface DirectoryBrowserProps { /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean - /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ + /** + * List one directory level (absent path = the Host home directory); the + * signal aborts a superseded scan on the wire. A rejection may carry + * `{ rpcError: { message: string } }`; the dialog prefers that Host + * business message over the ordinary Error text. + */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent. A rejection may + * carry `{ rpcError: { message: string } }`; the dialog prefers that Host + * business message over the ordinary Error text. + */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -63,9 +71,13 @@ export interface DirectoryBrowserProps { t: Translate } -/** Failure text: the Host business message when typed, else the throw's text. */ +/** Failure text from the injected directory operation. */ function failureText(error: unknown): string { - if (error instanceof DirectoryBrowseError) return error.rpcError.message + if (error !== null && typeof error === 'object' && 'rpcError' in error) { + const rpcError = error.rpcError + if (rpcError !== null && typeof rpcError === 'object' && 'message' in rpcError + && typeof rpcError.message === 'string') return rpcError.message + } return error instanceof Error ? error.message : String(error) } diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 6c1d35a76b..69a624eb38 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' -import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) @@ -81,7 +80,7 @@ function listingFor(path?: string): DirectoryListing { } const found = tree[target] if (found === undefined) { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } }) + throw new Error(`cannot list ${target}`) } return found } @@ -543,7 +542,7 @@ describe('DirectoryBrowser', () => { it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { const listDirectory = vi.fn(async (path?: string) => { if (path === `${HOME}/.config`) { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } }) + throw new Error('denied') } return listingFor(path) }) @@ -564,7 +563,7 @@ describe('DirectoryBrowser', () => { it('leaves focus on a surviving row when its pick fails', async () => { const listDirectory = vi.fn(async (path?: string) => { if (path === DOCS) { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } }) + throw new Error('denied') } return listingFor(path) }) @@ -587,7 +586,7 @@ describe('DirectoryBrowser', () => { // The initial open lists home through the absent-path form; only the // parent leg names HOME explicitly. if (path === HOME) { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } }) + throw new Error('parent gone') } return listingFor(path) }) @@ -1286,7 +1285,9 @@ describe('DirectoryBrowser', () => { it('keeps path entry available when the home listing fails', async () => { const listDirectory = vi.fn(async (): Promise => { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } }) + throw Object.assign(new Error('directory browse failed: directory-unreadable: home unreadable'), { + rpcError: { code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } }, + }) }) mount({ listDirectory }) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('home unreadable') }) @@ -1303,6 +1304,14 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) }) + it('falls back to the thrown message for an invalid RPC error payload', async () => { + const listDirectory = vi.fn(async (): Promise => { + throw Object.assign(new Error('home unavailable'), { rpcError: null }) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('home unavailable') }) + }) + it('disables Open and New folder while a path draft is uncommitted', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -1346,7 +1355,7 @@ describe('DirectoryBrowser', () => { fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) b.listDirectory.mockImplementation(async () => { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: HOME } }) + throw new Error('denied') }) fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) @@ -1453,7 +1462,7 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) b.listDirectory.mockImplementation(async () => { - throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }) + throw new Error('denied') }) fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) @@ -1574,7 +1583,7 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) b.createDirectory.mockRejectedValueOnce( - new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) + new Error('taken already')) fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy() const input = screen.getByLabelText('browser.folderName') diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 4489bbe5af..d87637ed67 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index b2507cfc12..9a477a91e6 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -9,12 +9,15 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { + ArbitrateKey, ArbitrateOutcome, PickOutcome, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' import type { - ArbitrateKey, ArbitrateOutcome, ClientSessionContext, PickOutcome, InputTriggerSource, SubmitEnvelope, TriggerChar, TriggerGuard, + ClientSessionContext, InputTriggerSource, SubmitEnvelope, TriggerChar, TriggerGuard, } from '../types.ts' /** Roster access the controller borrows from the root service (registration order preserved). */ diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 64f48ea64a..2c819c6f50 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -7,7 +7,7 @@ // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { InputTriggerService } from './service.ts' @@ -60,7 +60,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-input-trigger: menu dictionaries') ctx.inject(['slots', 'inputTriggers', 'sessions'], (scope: ClientContext) => { const inputTriggers = scope.inputTriggers - const sessions = resolveClientSessions(scope) + const sessions = scope.sessions scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'slash-menu', diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts index 9c11c01324..685364f489 100644 --- a/packages/client/ui-input-trigger/src/core/contract.ts +++ b/packages/client/ui-input-trigger/src/core/contract.ts @@ -4,7 +4,8 @@ * live in sibling modules annotated with these * aliases; the service shell wires them to ctx. */ -import type { InputTriggerCandidate, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition } from '../types.ts' +import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { InputTriggerCandidate, TriggerChar, TriggerGuard, TriggerPosition } from '../types.ts' /** A detected trigger token under the caret. */ export interface TriggerHit { diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 43f1012993..9cf41c62a1 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -7,9 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { - createScope, resolveClientSessions, scopeOf, -} from '@deepseek-ai/dsh-api-session-controller/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -80,7 +78,7 @@ describe('apply', () => { const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected const injected = injectEntry(sid('a')) const controller = inputTriggers.sessionOf( - resolveClientSessions(ctx).scope(sid('a'))!, + ctx.sessions.scope(sid('a'))!, ) expect(injected.menu).toBe(controller.menu) // The pick face routes into the controller pipeline (closed menu → no-op). diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 04d454ccb3..0fb58c8207 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index a2359a87b3..f5fd706fb4 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -13,7 +13,7 @@ */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-session-controller/types' -import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). @@ -125,7 +125,7 @@ export function apply(ctx: ClientContext): void { ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => { const command = scope.get('commandUi') as CommandUiContract const models = scope.modelDirectories - const sessions = resolveClientSessions(scope) + const sessions = scope.sessions scope.effect(() => command.register({ name: 'model', description: t('command.description'), @@ -156,7 +156,7 @@ export function apply(ctx: ClientContext): void { // Entry 2: the composer's named model seat over the SAME directory. ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => { const models = scope.modelDirectories - const sessions = resolveClientSessions(scope) + const sessions = scope.sessions scope.slots.inject('conversation.input.model', () => scope.slots.register({ name: 'conversation.input.model', locale: NS, diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index ce981f6d14..0e1ce576c1 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -14,7 +14,7 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ModelDirectory } from './directory.ts' @@ -70,7 +70,7 @@ export class ModelDirectoryResolver extends Service { const { live } = this const existing = live.directories.get(sessionId) if (existing !== undefined) return existing - const sessions = resolveClientSessions(this.ctx) + const sessions = this.ctx.sessions const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`) const directory = new ModelDirectory( diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index d178d3dcef..cc0fc0cfb6 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-connection", diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index e7d346cfef..404d201039 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -15,7 +15,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' -import { resolveClientSessions, type SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/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). @@ -83,7 +83,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO */ export function apply(ctx: ClientContext): void { const command = ctx.get('commandUi') as CommandUiContract - const sessions = resolveClientSessions(ctx) + const sessions = ctx.sessions // This optional bundle and ui-conversation can load independently, so each // owns the same safety copy under its own locale namespace. /* jscpd:ignore-start */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx b/packages/client/ui-primitives/src/ReferenceIcon.tsx similarity index 97% rename from packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx rename to packages/client/ui-primitives/src/ReferenceIcon.tsx index fcc89fe30e..a225eba8fb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx +++ b/packages/client/ui-primitives/src/ReferenceIcon.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { IconBrowseOutline16, IconFolderClose16, -} from '@deepseek-ai/dsh-client-ui-primitives' +} from './icons/index.tsx' /** Reference domains with distinct composer and transcript glyphs. */ export type ReferenceIconKind = 'session' | 'file' | 'folder' diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 80734f08e9..a5198e51de 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -25,6 +25,8 @@ export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export type { BrandWordmarkProps } from './BrandWordmark.tsx' +export { ReferenceIcon } from './ReferenceIcon.tsx' +export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index c744093af2..d90bd9fbb2 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index c62df1051f..9b66956c2d 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -32,7 +32,7 @@ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' -import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void { )) const skills = (ctx.get('connection') as ConnectionHandle).api.skills - const sessions = resolveClientSessions(ctx) + const sessions = ctx.sessions // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 8579684fa6..6d63eb64a2 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -14,8 +14,10 @@ * consumer merges keys in and the intersection is what keeps them string-typed. * The rule fires on the empty-map view, not on real redundancy. */ import type { ReactNode } from 'react' +import type { + BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl, +} from '@deepseek-ai/dsh-client-store' import type { HostObservable } from './renderer.ts' -import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts' export * from './store.ts' export * from './renderer.ts' diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 775289d61b..5aeeb4189b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 5d9d6a7724..2660ba3a0f 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -3,8 +3,8 @@ import { } from 'react' import { createPortal } from 'react-dom' import { - indexSubagentDescendants, type SessionListState, type SessionProjectionMap, - type SessionSummary, type SubagentCatalogSnapshot, + type SessionListState, type SessionProjectionMap, type SessionSummary, + type SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -17,6 +17,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-subagent/client' import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentHeaderLineage.module.css' +import { indexSubagentDescendants } from './subagent-lineage.ts' type CatalogEntry = SubagentCatalogSnapshot['entries'][number] type Catalogs = SessionListState['subagentsByParent'] diff --git a/packages/client/ui-subagent/src/client/subagent-lineage.ts b/packages/client/ui-subagent/src/client/subagent-lineage.ts new file mode 100644 index 0000000000..d96ab4ff8a --- /dev/null +++ b/packages/client/ui-subagent/src/client/subagent-lineage.ts @@ -0,0 +1,46 @@ +/** UI Subagent-owned projection of descendant counts from Session summaries. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +interface LineageEntry { + readonly id: SessionId + readonly parentId?: SessionId + readonly origin?: 'subagent' + readonly running: boolean +} + +/** Descendant counts for one possible parent Session. */ +export interface SubagentDescendantSummary { + readonly count: number + readonly runningCount: number +} + +/* jscpd:ignore-start -- UI Subagent and UI Workspace independently project their own views. */ +/** + * Index uninterrupted subagent descendants under each ancestor. + * @param summaries - Session summaries keyed by id. + * @returns descendant totals keyed by possible parent id. + */ +export function indexSubagentDescendants( + summaries: Readonly>, +): ReadonlyMap { + const indexed = new Map() + for (const descendant of Object.values(summaries)) { + if (descendant.origin !== 'subagent') continue + const seen = new Set() + let current: LineageEntry | undefined = descendant + while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) { + seen.add(current.id) + const aggregate = indexed.get(current.parentId) + if (aggregate === undefined) { + indexed.set(current.parentId, { count: 1, runningCount: descendant.running ? 1 : 0 }) + } else { + aggregate.count += 1 + if (descendant.running) aggregate.runningCount += 1 + } + current = summaries[current.parentId] + } + } + return indexed +} +/* jscpd:ignore-end */ diff --git a/packages/client/ui-subagent/tests/subagent-lineage.client.spec.ts b/packages/client/ui-subagent/tests/subagent-lineage.client.spec.ts new file mode 100644 index 0000000000..17b3a8acdf --- /dev/null +++ b/packages/client/ui-subagent/tests/subagent-lineage.client.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { indexSubagentDescendants } from '../src/client/subagent-lineage.ts' + +const sid = (id: string): SessionId => id as SessionId + +describe('UI Subagent descendant projection', () => { + it('counts nested running descendants and stops at ordinary forks', () => { + const owner = { id: sid('owner'), running: false } + const child = { id: sid('child'), parentId: owner.id, origin: 'subagent' as const, running: false } + const grandchild = { id: sid('grandchild'), parentId: child.id, origin: 'subagent' as const, running: true } + const fork = { id: sid('fork'), parentId: child.id, running: false } + const forkChild = { id: sid('fork-child'), parentId: fork.id, origin: 'subagent' as const, running: true } + const result = indexSubagentDescendants(Object.fromEntries( + [owner, child, grandchild, fork, forkChild].map(item => [item.id, item]), + )) + expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 }) + expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 }) + expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 }) + }) + + it('terminates cycles and retains missing-parent aggregates', () => { + const cycleA = { id: sid('a'), parentId: sid('b'), origin: 'subagent' as const, running: false } + const cycleB = { id: sid('b'), parentId: sid('a'), origin: 'subagent' as const, running: false } + const orphan = { id: sid('orphan'), parentId: sid('missing'), origin: 'subagent' as const, running: true } + const result = indexSubagentDescendants({ [cycleA.id]: cycleA, [cycleB.id]: cycleB, [orphan.id]: orphan }) + expect(result.get(cycleA.id)?.count).toBe(2) + expect(result.get(cycleB.id)?.count).toBe(2) + expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 }) + }) +}) diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 8c93e10f21..0e38fce093 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-workspace-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", @@ -61,7 +58,8 @@ "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -80,7 +78,8 @@ "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index db31705d6a..b00915a433 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -13,8 +13,8 @@ * until the result arrives. * @module */ -import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' /** diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index c1adc23b1c..b8b25fa8bf 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -8,9 +8,9 @@ * are derived once. * @module */ -import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import type { ToolCallBlock } from './tool-call-model.ts' /** diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 9ea780b3e7..8af6fb5335 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -9,9 +9,9 @@ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). -import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 7b30739361..9abd3e0837 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../util/workspace-path" } ] } diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index e55e7a09df..5f4fa4981d 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-client-ui-conversation/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", @@ -64,7 +61,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -86,7 +84,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 6c0e0a3a30..98df63117e 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -1,12 +1,13 @@ import type { Context } from '@deepseek-ai/cordis' -import { - displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock, - toAssistantBlocks, - type AssistantBlock, type AssistantMessageNode, type ConversationLocation, - type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition, - type PartialAssistant, type RequestView, +import type { + AssistantBlock, AssistantMessageNode, ConversationLocation, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + PartialAssistant, RequestView, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { trajectoryNode } from './trajectory-definition-common.ts' +import { + displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, +} from './trajectory-event-projection.ts' /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event * state machines independent; see ../../../../../.agents/notes/implemented/ diff --git a/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts b/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts new file mode 100644 index 0000000000..d97aa40789 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts @@ -0,0 +1,159 @@ +/** Trajectory-owned conversion from durable Session events to ledger view data. */ + +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' +import type { + AssistantBlock, ContextProvenanceView, KnownContextForm, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/* jscpd:ignore-start -- Chat and Trajectory own independent event-to-view projections. */ + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function readString(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function collect(source: Record, member: string, field: string): string[] { + const list = source[member] + if (!Array.isArray(list)) return [] + const seen: string[] = [] + for (const entry of list) { + const record = asRecord(entry) + const value = record === null ? null : readString(record, field) + if (value !== null && !seen.includes(value)) seen.push(value) + } + return seen +} + +function joined(names: string[]): string | null { + return names.length > 0 ? names.join(', ') : null +} + +/** Forms Trajectory presents structurally; unknown merge-extensible values remain opaque. */ +const KNOWN_FORMS: readonly KnownContextForm[] = [ + 'instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall', +] + +/** + * Read the target-supported presentation form from a durable message source. + * @param source - Logged `user/message` source. + * @returns Supported form, or null for the opaque presentation. + */ +export function contextForm(source: unknown): KnownContextForm | null { + const record = asRecord(source) + const form = record === null ? null : readString(record, 'form') + return form !== null && (KNOWN_FORMS as readonly string[]).includes(form) + ? form as KnownContextForm + : null +} + +/** + * Project a durable message source to the Trajectory row's role and producer label. + * @param source - Logged `user/message` source. + * @returns Role and label rendered by Trajectory. + */ +export function contextProvenance(source: unknown): ContextProvenanceView { + const record = asRecord(source) + const kind = record === null ? null : readString(record, 'kind') + if (record === null || kind === null) return { role: 'inject', label: null } + switch (kind) { + case 'session-reference': + return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind } + case 'agent-instructions': + return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } + case 'plugin': + return { role: 'inject', label: readString(record, 'plugin') ?? kind } + case 'skill-invocation': + return { role: 'inject', label: readString(record, 'name') ?? kind } + default: + // MessageSourceMap is merge-extensible; keep an unknown producer + // visible by its durable kind. + return { role: 'inject', label: kind } + } +} + +/** + * Classify finalized Assistant content for Trajectory rendering. + * @param content - Core content blocks. + * @returns Trajectory blocks in source order. + */ +export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] { + return content.map(toAssistantBlock) +} + +/** + * Classify one finalized Assistant block for Trajectory rendering. + * @param block - Core content block. + * @returns Trajectory block. + */ +export function toAssistantBlock(block: ContentBlock): AssistantBlock { + switch (block.type) { + case 'text': return { kind: 'text', text: block.text } + case 'reasoning': return { kind: 'reasoning', text: block.text } + case 'image': return { kind: 'image', attachment: block.attachment } + case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments } + default: return { kind: 'other', block } + } +} + +/** + * Create the initial Trajectory block for one streamed Assistant block kind. + * @param blockType - Wire block kind. + * @returns Empty block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { + switch (blockType) { + case 'text': return { kind: 'text', text: '' } + case 'reasoning': return { kind: 'reasoning', text: '' } + case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } + default: return { kind: 'other', block: null } + } +} + +/** Display-safe failure fields retained by Trajectory projections. */ +export interface DisplayFailure { + readonly code?: string + readonly message: string +} + +/** + * Convert a durable failure to locale-independent fields safe for Trajectory. + * @param failure - Failure preserved by a Session event. + * @returns Sanitized message and optional stable provider code. + */ +export function displayFailure(failure: unknown): DisplayFailure { + if (failure === null || typeof failure !== 'object') return { message: String(failure) } + const record = failure as { code?: unknown; message?: unknown } + const code = typeof record.code === 'string' ? record.code : undefined + // Provider AUTH messages may echo a masked or partially preserved credential. + // Keep the raw diagnostic in the Session log, but never retain it in UI state. + if (code === 'AUTH') return { code, message: '' } + return { + ...(code === undefined ? {} : { code }), + message: typeof record.message === 'string' ? record.message : JSON.stringify(failure), + } +} + +/** + * Whether a stream chunk carries visible model output for Trajectory timing. + * @param chunk - Stream chunk to inspect. + * @returns true for a non-empty text, reasoning, or Tool-call delta. + */ +export function isTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +/* jscpd:ignore-end */ diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index 2dfc871ad4..d8b714197a 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -1,11 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' -import { - contextForm, contextProvenance, - type ContextMessageNode, type ConversationNodeDefinition, type ConversationPreviousContext, - type SteeringMessageNode, type UserMessageNode, +import type { + ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, + SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent/types' import { trajectoryNode } from './trajectory-definition-common.ts' +import { contextForm, contextProvenance } from './trajectory-event-projection.ts' /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event * state machines independent; see ../../../../../.agents/notes/implemented/ diff --git a/packages/client/ui-trajectory/tests/event-projection.client.spec.ts b/packages/client/ui-trajectory/tests/event-projection.client.spec.ts new file mode 100644 index 0000000000..9d2d8ff433 --- /dev/null +++ b/packages/client/ui-trajectory/tests/event-projection.client.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import { + contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta, + toAssistantBlock, toAssistantBlocks, +} from '../src/client/trajectory-event-projection.ts' + +describe('Trajectory event projection', () => { + it('projects known, unknown, and unreadable context sources', () => { + expect(contextProvenance({ kind: 'session-reference', references: [{ label: 'A' }, { label: 'A' }] })) + .toEqual({ role: 'recall', label: 'A' }) + expect(contextProvenance({ kind: 'session-reference', references: [] })) + .toEqual({ role: 'recall', label: 'session-reference' }) + expect(contextProvenance({ kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }, null] })) + .toEqual({ role: 'inject', label: 'AGENTS.md' }) + expect(contextProvenance({ kind: 'agent-instructions', changes: 'bad' }).label) + .toBe('agent-instructions') + expect(contextProvenance({ kind: 'plugin', plugin: 'p' }).label).toBe('p') + expect(contextProvenance({ kind: 'plugin', plugin: 1 }).label).toBe('plugin') + expect(contextProvenance({ kind: 'skill-invocation', name: 's' }).label).toBe('s') + expect(contextProvenance({ kind: 'future' }).label).toBe('future') + expect(contextProvenance(null)).toEqual({ role: 'inject', label: null }) + expect(contextProvenance([])).toEqual({ role: 'inject', label: null }) + expect(contextProvenance({ kind: '' })).toEqual({ role: 'inject', label: null }) + }) + + it('accepts only forms supported by the target', () => { + for (const form of ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall']) { + expect(contextForm({ form })).toBe(form) + } + expect(contextForm({ form: 'future' })).toBeNull() + expect(contextForm({ form: 1 })).toBeNull() + expect(contextForm(null)).toBeNull() + }) + + it('projects finalized and empty Assistant blocks', () => { + const content = [ + { type: 'text', text: 'a' }, + { type: 'reasoning', text: 'b' }, + { type: 'image', attachment: { attachmentId: 'x' } }, + { type: 'tool-call', id: 'c', name: 'n', arguments: '{}' }, + { type: 'future' }, + ] as unknown as ContentBlock[] + expect(toAssistantBlocks(content).map(block => block.kind)) + .toEqual(['text', 'reasoning', 'image', 'tool-call', 'other']) + expect(toAssistantBlock(content[0]!)).toEqual({ kind: 'text', text: 'a' }) + expect(['text', 'reasoning', 'tool-call', 'future'].map(emptyAssistantBlock)) + .toEqual([ + { kind: 'text', text: '' }, + { kind: 'reasoning', text: '' }, + { kind: 'tool-call', callId: '', name: '', argsRaw: '' }, + { kind: 'other', block: null }, + ]) + }) + + it('redacts auth failures and presents other durable values', () => { + expect(displayFailure({ code: 'AUTH', message: 'secret' })).toEqual({ code: 'AUTH', message: '' }) + expect(displayFailure({ message: 'offline' })).toEqual({ message: 'offline' }) + expect(displayFailure({ code: 'UNKNOWN' })).toEqual({ code: 'UNKNOWN', message: '{"code":"UNKNOWN"}' }) + expect(displayFailure(undefined)).toEqual({ message: 'undefined' }) + }) + + it('recognizes only non-empty token deltas', () => { + expect(isTokenDelta({ type: 'text-delta', index: 0, text: 'x' } as never)).toBe(true) + expect(isTokenDelta({ type: 'reasoning-delta', index: 0, text: '' } as never)).toBe(false) + expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '}' } as never)).toBe(true) + expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' } as never)).toBe(false) + expect(isTokenDelta({ type: 'finish', reason: 'stop' } as never)).toBe(false) + }) +}) diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index f18a03de31..8bd120c437 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../llm/llm" } ] } diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 788b8b6e2c..8db49c53e7 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-client-ui-conversation/client" - ], "inject": [ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 0a65ef79eb..8c2bee5c1a 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -1,7 +1,6 @@ /** Question composer props and one pending Remote waterfall response. */ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // The client module declares the conversation.composer SlotMap entry required by PropsRuntime. -import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AskUserQuestionAnswer, AskUserQuestionItem, @@ -23,6 +22,19 @@ type QuestionItem = AskUserQuestionItem /** One option the asker offered on a question. */ type QuestionOption = NonNullable[number] +/* jscpd:ignore-start -- Question and Approval intentionally own independent pending-settlement lifecycles. */ +function settlePendingComposer(settle: () => void, failureMessage: string): Promise { + try { + settle() + return Promise.resolve() + } catch (error) { + return Promise.reject(error instanceof Error + ? error + : new Error(failureMessage, { cause: error })) + } +} +/* jscpd:ignore-end */ + /** * A request narrowed to the `plan-review` presentation intent: everything the * decision card renders and answers with, so the panel never re-reads the diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 5344c80ed7..77956258fb 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -14,6 +14,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client' @@ -56,7 +57,7 @@ async function answerQuestion( next: ClientQuestionNext, registerPendingInteraction: PendingInteractionPublisher, ): Promise { - const sessionId = ctx.sessions.scopeOf(owner) + const sessionId = (ctx.sessions as ISessions).scopeOf(owner) if (sessionId === undefined) return next() const pending = new PendingQuestion(sessionId, request.questions, request.signal) const completed = Promise.withResolvers() diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 73b7c6650d..a50f80911e 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -31,10 +31,6 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-session-controller/client", - "@deepseek-ai/dsh-api-workspace-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", @@ -67,7 +63,8 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-session-controller": "workspace:^", @@ -84,6 +81,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index b33adad512..da06e95cc9 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -29,7 +29,6 @@ import { WorkspaceBrowser } from './rows/WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' import { en, zh, type WorkspaceKey } from './locales.ts' -export { DirectoryBrowseError } from './navigation.ts' export type { UiWorkspace } from './navigation.ts' export type { DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 7d209baec9..1135f60b2e 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -31,7 +31,10 @@ export interface UiWorkspace { * @param sessionId - Session to archive. */ archiveSession(sessionId: SessionId): Promise - /** @returns the Host-native picked directory, or null when cancelled. */ + /** + * Open the Host-native directory picker. + * @returns the selected directory, or null when cancelled. + */ pickDirectory(): Promise /** * List one Host directory level. diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 06ed2f73d7..074f51a13c 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -13,7 +13,7 @@ import { IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' -import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' diff --git a/packages/client/ui-workspace/src/client/subagent-lineage.ts b/packages/client/ui-workspace/src/client/subagent-lineage.ts new file mode 100644 index 0000000000..bd145d723a --- /dev/null +++ b/packages/client/ui-workspace/src/client/subagent-lineage.ts @@ -0,0 +1,46 @@ +/** UI Workspace-owned projection of descendant counts from Session summaries. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +interface LineageEntry { + readonly id: SessionId + readonly parentId?: SessionId + readonly origin?: 'subagent' + readonly running: boolean +} + +/** Descendant counts for one possible parent Session. */ +export interface SubagentDescendantSummary { + readonly count: number + readonly runningCount: number +} + +/* jscpd:ignore-start -- UI Subagent and UI Workspace independently project their own views. */ +/** + * Index uninterrupted subagent descendants under each ancestor. + * @param summaries - Session summaries keyed by id. + * @returns descendant totals keyed by possible parent id. + */ +export function indexSubagentDescendants( + summaries: Readonly>, +): ReadonlyMap { + const indexed = new Map() + for (const descendant of Object.values(summaries)) { + if (descendant.origin !== 'subagent') continue + const seen = new Set() + let current: LineageEntry | undefined = descendant + while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) { + seen.add(current.id) + const aggregate = indexed.get(current.parentId) + if (aggregate === undefined) { + indexed.set(current.parentId, { count: 1, runningCount: descendant.running ? 1 : 0 }) + } else { + aggregate.count += 1 + if (descendant.running) aggregate.runningCount += 1 + } + current = summaries[current.parentId] + } + } + return indexed +} +/* jscpd:ignore-end */ diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index e03dab70a6..01c6ad644b 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -4,14 +4,17 @@ * remains visible. */ import { - indexSubagentDescendants, type SessionListState, - type SessionSearchResultItem, type SessionSummary, type SubagentDescendantSummary, + type SessionListState, type SessionSearchResultItem, type SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionBase, } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import { + indexSubagentDescendants, type SubagentDescendantSummary, +} from './subagent-lineage.ts' /** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' @@ -104,8 +107,8 @@ interface Group { */ export function workspaceLabel(cwd: string | undefined): string { if (cwd === undefined || cwd === '') return '' - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - return base !== undefined && base !== '' ? base : cwd + const base = workspaceTitleOf(cwd) + return base !== '' ? base : cwd } /** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */ diff --git a/packages/client/ui-workspace/tests/subagent-lineage.client.spec.ts b/packages/client/ui-workspace/tests/subagent-lineage.client.spec.ts new file mode 100644 index 0000000000..cefcad61c2 --- /dev/null +++ b/packages/client/ui-workspace/tests/subagent-lineage.client.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { indexSubagentDescendants } from '../src/client/subagent-lineage.ts' + +const sid = (id: string): SessionId => id as SessionId + +describe('UI Workspace descendant projection', () => { + it('counts nested running descendants and stops at ordinary forks', () => { + const root = { id: sid('root'), running: false } + const child = { id: sid('child'), parentId: root.id, origin: 'subagent' as const, running: true } + const leaf = { id: sid('leaf'), parentId: child.id, origin: 'subagent' as const, running: false } + const fork = { id: sid('fork'), parentId: child.id, running: false } + const result = indexSubagentDescendants(Object.fromEntries( + [root, child, leaf, fork].map(item => [item.id, item]), + )) + expect(result.get(root.id)).toEqual({ count: 2, runningCount: 1 }) + expect(result.get(child.id)).toEqual({ count: 1, runningCount: 0 }) + expect(result.has(fork.id)).toBe(false) + }) + + it('terminates cycles and retains missing-parent aggregates', () => { + const a = { id: sid('a'), parentId: sid('b'), origin: 'subagent' as const, running: false } + const b = { id: sid('b'), parentId: sid('a'), origin: 'subagent' as const, running: false } + const orphan = { id: sid('orphan'), parentId: sid('missing'), origin: 'subagent' as const, running: true } + const result = indexSubagentDescendants({ [a.id]: a, [b.id]: b, [orphan.id]: orphan }) + expect(result.get(a.id)?.count).toBe(2) + expect(result.get(b.id)?.count).toBe(2) + expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 }) + }) +}) diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 7c128e187e..9c2dfd4fb8 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -14,8 +14,7 @@ import { type RpcResponse, } from '@deepseek-ai/dsh-client-connection/client' import { SessionId } from '@deepseek-ai/dsh-session/types' -import { DirectoryBrowseError } from '../src/client/index.ts' -import { UiWorkspaceService } from '../src/client/navigation.ts' +import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' const sid = (id: string): SessionId => SessionId(id) const wid = (id: string): WorkspaceId => id as WorkspaceId diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 80bc10dc0d..5d7708e031 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/workspace-path" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 216652dc05..fce084cf6a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -1,7 +1,7 @@ /** - * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run - * `pnpm run gen-cordis-api` to regenerate (freshness-gated by - * `pnpm run verify-cordis-api` in doc-sync). + * Generated by scripts/gen-cordis-inspect-catalog.ts — do not edit by hand; run + * `pnpm run gen-cordis-inspect-catalog` to regenerate (freshness-gated by + * `pnpm run verify-cordis-inspect-catalog` in doc-sync). * * The machine-readable cordis API catalog `cordis_inspect` serves to the * model: harness services (summary + structured public method contracts), @@ -184,7 +184,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'completion of the current or newly started refresh.', }, { - signature: 'search( query: string, signal: AbortSignal, ): Promise>', + signature: 'search( query: string, signal: AbortSignal, ): Promise>', description: 'Search the Host\'s visible message-content index. Results stay request-local; the list snapshot remains the metadata authority.', parameters: [{ name: 'query', description: 'non-blank literal phrase.' }, { name: 'signal', description: 'cancellation for a superseded search.' }], returns: 'bounded results, or a business/transport error.', @@ -297,71 +297,83 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ ], }, { - key: 'workspaces', - summary: 'The workspaces-service face injected as `ctx.workspaces`.', - description: 'The workspaces-service face injected as `ctx.workspaces`.', + key: 'uiWorkspace', + summary: 'Workspace archive and directory operations consumed by Client UI domains.', + description: 'Workspace archive and directory operations consumed by Client UI domains.', methods: [ { signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise', - description: 'Connect a Workspace to its reusable or freshly created blank session.', - parameters: [{ name: 'workspaceId', description: 'target workspace.' }], - returns: 'the connected session id.', + description: 'Resolve the reusable or newly created blank Session for a Workspace.', + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }], + returns: 'a Session already addressable through the Session Controller.', }, { signature: 'startSession(workspaceId?: WorkspaceId): void', - description: 'The New Session flow: connect the explicit, current-Session, or recent Workspace and open the resulting session; failures surface on the session list state.', - parameters: [{ name: 'workspaceId', description: 'explicit target; omitted inherits the current Session\'s Workspace before falling back to the recency projection.' }], + description: 'Start a New Session flow and navigate to its Session.', + parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }], }, { - signature: 'create(input: { path: string }): Promise', - description: 'Register an existing path as a Workspace.', - parameters: [{ name: 'input', description: 'the Host create payload.' }], - returns: 'the created or idempotently resolved Workspace.', + signature: 'archiveSession(sessionId: SessionId): Promise', + description: 'Archive a Session and clear it when it is the current selection.', + parameters: [{ name: 'sessionId', description: 'Session to archive.' }], }, { signature: 'pickDirectory(): Promise', - description: 'Open the Host\'s native directory picker.', + description: 'Open the Host-native directory picker.', parameters: [], - returns: 'the selected path, or null when the user cancelled.', + returns: 'the selected directory, or null when cancelled.', }, { signature: 'listDirectory(path?: string, signal?: AbortSignal): Promise', - description: 'List one directory level through the Host\'s `browse` capability.', - parameters: [{ name: 'path', description: 'absolute directory to list; absent lists the Host home directory.' }, { name: 'signal', description: 'aborts the wire request (and the Host\'s scan) when the caller supersedes it.' }], - returns: 'the level\'s listing with breadcrumb ancestry.', + description: 'List one Host directory level.', + parameters: [{ name: 'path', description: 'directory path; absent selects the Host home.' }, { name: 'signal', description: 'cancellation for a superseded scan.' }], + returns: 'directory entries and breadcrumb ancestry.', }, { signature: 'createDirectory(path: string, name: string): Promise', - description: 'Create one child directory through the Host\'s `browse` capability.', - parameters: [{ name: 'path', description: 'absolute existing parent directory.' }, { name: 'name', description: 'single non-blank path segment.' }], - returns: 'the created directory\'s absolute path.', + description: 'Create a child directory.', + parameters: [{ name: 'path', description: 'existing parent directory.' }, { name: 'name', description: 'child directory name.' }], + returns: 'created absolute path.', }, { signature: 'openPath(path: string): Promise', - description: 'Open a filesystem path with the Host operating system\'s default application.', - parameters: [{ name: 'path', description: 'absolute or host-resolvable path.' }], + description: 'Open a path with the Host operating system.', + parameters: [{ name: 'path', description: 'absolute or Host-resolvable path.' }], + }, + ], + }, + { + key: 'workspaces', + summary: 'Workspace Controller\'s Client service face.', + description: 'Workspace Controller\'s Client service face.', + methods: [ + { + signature: 'create(input: { path: string }): Promise', + description: 'Register an existing path as a Workspace.', + parameters: [{ name: 'input', description: 'Host create payload.' }], + returns: 'the created or idempotently resolved Workspace.', }, { signature: 'rename(workspaceId: WorkspaceId, title: string): Promise', description: 'Rename a Workspace.', - parameters: [{ name: 'workspaceId', description: 'target workspace.' }, { name: 'title', description: 'the new display title.' }], - returns: 'the updated Workspace view.', + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'title', description: 'new display title.' }], + returns: 'the renamed Workspace.', }, { signature: 'delete(workspaceId: WorkspaceId): Promise', - description: 'Delete a Workspace (its sessions fall back to the unaccounted group).', - parameters: [{ name: 'workspaceId', description: 'target workspace.' }], - }, - { - signature: 'insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise', - description: 'Move an accounted session within/into a Workspace\'s ordered list.', - parameters: [{ name: 'workspaceId', description: 'target workspace.' }, { name: 'sessionId', description: 'accounted session to move.' }, { name: 'beforeSessionId', description: 'accounted anchor to insert before; omitted appends.' }], - returns: 'the updated Workspace view.', + description: 'Delete a Workspace registration without deleting Sessions or files.', + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }], }, { signature: 'archiveSession(sessionId: SessionId): Promise', - description: 'Archive a session into the registry-global set (hidden from grouping surfaces; session log and accounting slot remain). Archiving the current session clears the selection into the New Session view state.', - parameters: [{ name: 'sessionId', description: 'session to archive.' }], + description: 'Archive a Session from Workspace grouping surfaces.', + parameters: [{ name: 'sessionId', description: 'Session to archive.' }], + }, + { + signature: 'insertSessionBefore( workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId, ): Promise', + description: 'Move a Session within one Workspace account.', + parameters: [{ name: 'workspaceId', description: 'owning Workspace.' }, { name: 'sessionId', description: 'Session to move.' }, { name: 'beforeSessionId', description: 'anchor Session; omitted appends.' }], + returns: 'the changed Workspace.', }, ], }, @@ -373,8 +385,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'connection/reset', mode: 'emit', signature: '\'connection/reset\'(): void', - summary: 'A connection generation was (re-)established.', - description: 'A connection generation was (re-)established. Wire-derived caches must treat their state as stale and repull (commands directory; the queue mirrors reset themselves through the session resync path).', + summary: 'A connection generation was established.', + description: 'A connection generation was established. Wire-derived caches must repull; long-lived streams own their own resume and baseline lifecycle.', parameters: [], }, { @@ -389,9 +401,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'slots/changed', mode: 'emit', signature: '\'slots/changed\'(key: string): void', - summary: 'A slot\'s definition or registration set changed.', - description: 'A slot\'s definition or registration set changed.', - parameters: [{ name: 'key', description: 'the mutated SlotMap key.' }], + summary: 'A slot declaration or registration set changed.', + description: 'A slot declaration or registration set changed.', + parameters: [{ name: 'key', description: 'mutated SlotMap key.' }], }, { name: 'theme/change', @@ -411,27 +423,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentContext', - declaration: 'export type AgentContext = Omit & {\n readonly remote: TypertClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};', - }, - { - name: 'AssistantBlock', - declaration: 'export type AssistantBlock = {\n kind: \'text\';\n text: string;\n} | {\n kind: \'reasoning\';\n text: string;\n} | {\n kind: \'image\';\n attachment: ImageAttachmentRef;\n} | {\n kind: \'tool-call\';\n callId: string;\n name: string;\n argsRaw: string;\n} | {\n kind: \'other\';\n block: unknown;\n};', - }, - { - name: 'AssistantMessageNode', - declaration: 'export interface AssistantMessageNode {\n kind: \'assistant\';\n seq: number;\n messageId?: MessageId;\n time: number;\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n usage?: unknown;\n provenance?: AssistantProvenanceView;\n requestConfig?: AssistantRequestConfig;\n timing?: AssistantTiming;\n interrupted?: true;\n}', - }, - { - name: 'AssistantProvenanceView', - declaration: 'export interface AssistantProvenanceView {\n provider: string;\n model: string;\n}', - }, - { - name: 'AssistantRequestConfig', - declaration: 'export interface AssistantRequestConfig {\n provider: string;\n model: string;\n purpose?: string;\n thinking?: string;\n reasoningEffort?: string;\n temperature?: number;\n maxTokens?: number;\n stop?: readonly string[];\n}', - }, - { - name: 'AssistantTiming', - declaration: 'export interface AssistantTiming {\n stepStartTime: number | null;\n firstTokenTime: number | null;\n completedTime: number;\n}', + declaration: 'export type AgentContext = Omit & {\n readonly remote: ClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};', }, { name: 'BakedActions', @@ -447,99 +439,55 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ChainRenderOpts', - declaration: 'export interface ChainRenderOpts {\n fallback?: ReactNode;\n overlay?: boolean;\n}', - }, - { - name: 'ChatConversationViewNode', - declaration: 'export interface ChatConversationViewNode extends ConversationViewNode {\n readonly target: \'chat\';\n readonly anchorSeq: number;\n readonly location: ConversationLocation;\n readonly visibility: \'visible\' | \'hidden\';\n}', - }, - { - name: 'ChatLocationNodeIndex', - declaration: 'export interface ChatLocationNodeIndex {\n getTurn(turn: number): readonly string[];\n getStep(turn: number, step: number): readonly string[];\n}', - }, - { - name: 'ChatNodeStore', - declaration: 'export interface ChatNodeStore {\n get(key: string): ChatConversationViewNode | undefined;\n values(): readonly ChatConversationViewNode[];\n}', - }, - { - name: 'ChatSnapshot', - declaration: 'export interface ChatSnapshot {\n readonly order: readonly string[];\n readonly nodes: ChatNodeStore;\n readonly locations: ChatLocationNodeIndex;\n readonly timeline: ConversationTimelineSnapshot;\n readonly legacy: LegacyConversationSlice;\n}', + declaration: 'export interface ChainRenderOpts {\n fallback?: ReactNode;\n fallbackOnly?: boolean;\n overlay?: boolean;\n}', }, { name: 'ChildrenDecl', declaration: 'export type ChildrenDecl = {\n [P in keyof SlotMap & string]?: SlotSpec;\n};', }, { - name: 'CommandNode', - declaration: 'export interface CommandNode {\n kind: \'command\';\n seq: number;\n time: number;\n commandId: CommandId;\n name: string | null;\n args: string | null;\n outcome: {\n kind: \'success\' | \'error\';\n text?: string;\n sourceEventSeq?: number;\n } | null;\n}', + name: 'ClientConnectionRpc', + declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable;\n}', + }, + { + name: 'ClientRemote', + declaration: 'export interface ClientRemote extends TypertClientRemote {\n $stream(options: RemoteStreamOptions): RemoteStream;\n}', }, { name: 'CommonKeyOf', declaration: 'export type CommonKeyOf = LocaleNamespaceMap extends {\n common: infer C;\n} ? C & string : never;', }, - { - name: 'CompactionSummaryNode', - declaration: 'export interface CompactionSummaryNode {\n kind: \'compaction\';\n seq: number;\n time: number;\n summary: string | null;\n summaryEventSeq: number | null;\n shadowedItemCount: number | null;\n shadowedTokenCount: number | null;\n}', - }, { name: 'ComposedProps', declaration: 'export type ComposedProps, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale;', }, { - name: 'ComposerPhase', - declaration: 'export type ComposerPhase = \'blank\' | \'engaging\' | \'active\';', + name: 'ConnectionConfig', + declaration: 'export interface ConnectionConfig {\n backoffBaseMs?: number;\n backoffFactor?: number;\n backoffMaxMs?: number;\n generationReadyTimeoutMs?: number;\n}', }, { - name: 'ContextMessageNode', - declaration: 'export interface ContextMessageNode {\n kind: \'context\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n provenance: ContextProvenanceView;\n form: KnownContextForm | null;\n}', + name: 'ConnectionGenerationSource', + declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: () => void) => Promise;', }, { - name: 'ContextProvenanceView', - declaration: 'export interface ContextProvenanceView {\n role: ContextRole;\n label: string | null;\n}', + name: 'ConnectionHandle', + declaration: 'export interface ConnectionHandle {\n readonly api: IApiClient;\n readonly isLoopback: boolean;\n readonly hostDescription: HostDescriptionSource;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', }, { - name: 'ContextRole', - declaration: 'export type ContextRole = \'inject\' | \'recall\';', + name: 'ConnectionRpcFailure', + declaration: 'export interface ConnectionRpcFailure {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n}', }, { - name: 'ConversationLocation', - declaration: 'export type ConversationLocation = {\n readonly kind: \'session\';\n} | {\n readonly kind: \'turn\';\n readonly turn: TurnLocation;\n} | {\n readonly kind: \'step\';\n readonly turn: TurnLocation;\n readonly step: StepLocation;\n} | {\n readonly kind: \'unresolved\';\n};', + name: 'ConnectionRpcResult', + declaration: 'export type ConnectionRpcResult = {\n readonly ok: true;\n readonly value: T;\n} | {\n readonly ok: false;\n readonly error: ConnectionRpcFailure;\n};', }, { - name: 'ConversationLocationDataStore', - declaration: 'export interface ConversationLocationDataStore {\n get(key: Key): Readonly | undefined;\n}', + name: 'ConnectionSinks', + declaration: 'export interface ConnectionSinks {\n onConnected?: (description: HostDescription) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', }, { - name: 'ConversationNode', - declaration: 'export type ConversationNode = UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | TurnErrorNode | TurnMaxTokensNode | ToolResultNode | CommandNode | CompactionSummaryNode | UnknownSurfaceNode;', - }, - { - name: 'ConversationSnapshot', - declaration: 'export interface ConversationSnapshot {\n sessionId: SessionId;\n views: ConversationViewSnapshotStore;\n chat: ChatSnapshot;\n nodes: readonly ConversationNode[];\n turnTimings: ReadonlyMap;\n turnEnds: ReadonlyMap;\n partial: PartialAssistant | null;\n runningCalls: readonly RunningToolCall[];\n pending: readonly PendingInteraction[];\n queue: readonly QueuedMessage[];\n running: boolean;\n subagent: {\n address: SubagentAddress;\n parentAvailable: boolean;\n } | null;\n composerPhase: ComposerPhase;\n removed: boolean;\n openState: OpenState;\n openError: RpcError | null;\n hasMore: boolean;\n loadingOlder: boolean;\n promptError: PromptError | null;\n blank: boolean;\n lastAgentError: string | null;\n}', - }, - { - name: 'ConversationStepDataMap', - declaration: 'export interface ConversationStepDataMap {\n}', - }, - { - name: 'ConversationTimelineSnapshot', - declaration: 'export interface ConversationTimelineSnapshot {\n readonly turnOrder: readonly number[];\n readonly turns: ReadonlyMap;\n}', - }, - { - name: 'ConversationTurnDataMap', - declaration: 'export interface ConversationTurnDataMap {\n}', - }, - { - name: 'ConversationViewNode', - declaration: 'export interface ConversationViewNode {\n readonly key: string;\n readonly kind: string;\n readonly id: string;\n readonly target: string;\n readonly data: unknown;\n}', - }, - { - name: 'ConversationViewSnapshotMap', - declaration: 'export interface ConversationViewSnapshotMap {\n}', - }, - { - name: 'ConversationViewSnapshotStore', - declaration: 'export interface ConversationViewSnapshotStore {\n get>(target: Target): ConversationViewSnapshotMap[Target] | undefined;\n}', + name: 'ConnectionState', + declaration: 'export type ConnectionState = \'connected\' | \'reconnecting\';', }, { name: 'EntryKeyOf', @@ -557,9 +505,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HooksSources', declaration: 'export type HooksSources = Record>;', }, + { + name: 'HostDescription', + declaration: 'export type HostDescription = import(\'@deepseek-ai/dsh-host-apiproxy/api\').ResponseValue<\'host.describe\'>;', + }, + { + name: 'HostDescriptionSource', + declaration: 'export interface HostDescriptionSource {\n getSnapshot(): HostDescription | undefined;\n subscribe(listener: () => void): () => void;\n}', + }, { name: 'HostObservable', - declaration: 'export interface HostObservable {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}', + declaration: 'export type HostObservable = ObservableSnapshot;', }, { name: 'InjectFace', @@ -571,20 +527,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', declaration: 'export type KeyPropsOf> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? EntryKey extends keyof P ? P[EntryKey] extends object ? P[EntryKey] : never : never : object;', }, - { - name: 'KnownContextForm', - declaration: 'export type KnownContextForm = typeof KNOWN_FORMS[number];', - }, - { - name: 'LegacyConversationSlice', - declaration: 'export interface LegacyConversationSlice {\n readonly nodes: readonly ConversationNode[];\n readonly turnTimings: ReadonlyMap;\n readonly turnEnds: ReadonlyMap;\n readonly partial: PartialAssistant | null;\n readonly runningCalls: readonly RunningToolCall[];\n}', - }, { name: 'LocaleDefinition', declaration: 'export interface LocaleDefinition {\n id: LocaleId;\n label: string;\n}', @@ -617,10 +565,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MatchedShare', declaration: 'export type MatchedShare = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;', }, - { - name: 'ModelRetryNode', - declaration: 'export type ModelRetryNode = LlmRetryEventData & {\n kind: \'model-retry\';\n seq: number;\n time: number;\n retryState: \'scheduled\' | \'started\' | \'cancelled\';\n};', - }, { name: 'ObservableSnapshot', declaration: 'export interface ObservableSnapshot {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}', @@ -633,33 +577,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OwnerOf', declaration: 'export type OwnerOf = SlotMap[K] extends {\n owner: infer O extends object;\n} ? O : object;', }, - { - name: 'PartialAssistant', - declaration: 'export interface PartialAssistant {\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n}', - }, - { - name: 'PendingInteraction', - declaration: 'export type PendingInteraction = {\n [K in PendingKind]: PendingWait;\n}[PendingKind];', - }, - { - name: 'PendingKind', - declaration: 'export type PendingKind = keyof PendingPayloads;', - }, - { - name: 'PendingPayloads', - declaration: 'export interface PendingPayloads {\n approval: Omit, \'type\' | \'sessionId\'>;\n question: Omit, \'type\' | \'sessionId\'>;\n}', - }, - { - name: 'PendingWait', - declaration: 'export class PendingWait {\n readonly kind: K;\n readonly key: string;\n readonly sessionId: SessionId;\n readonly payload: PendingPayloads[K];\n constructor(kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], respond: (message: ClientResponse) => Promise);\n respond(result: ClientResponse[\'result\']): Promise;\n markSettled(): void;\n}', - }, { name: 'ProjectionsFace', declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot;\n}', }, + { + name: 'PromptContentPart', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + }, { name: 'PromptError', - declaration: 'export interface PromptError {\n op: \'send\' | \'stop\';\n error: RpcError;\n}', + declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: ClientFailure;\n}', }, { name: 'PropsHooks', @@ -686,12 +614,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export type PropsStore = H extends StoreHandle ? {\n useStore: SnapshotSelectorHook;\n actions: BakedActions;\n} : object;', }, { - name: 'QueueAction', - declaration: 'export type QueueAction = Parameters[1];', + name: 'RemoteStream', + declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', }, { - name: 'RunningToolCall', - declaration: 'export interface RunningToolCall {\n callId: string;\n name: string;\n argsRaw: string;\n turn: number;\n step: number;\n time: number;\n callView: ToolCallView | null;\n subCalls: readonly ToolCallBlock[];\n}', + name: 'RemoteStreamCarrierError', + declaration: 'export class RemoteStreamCarrierError extends Error {\n constructor(message: string, options?: ErrorOptions);\n}', + }, + { + name: 'RemoteStreamItem', + declaration: 'export interface RemoteStreamItem {\n readonly generation: number;\n readonly value: Item;\n readonly signal: AbortSignal;\n accept(): void;\n}', + }, + { + name: 'RemoteStreamOptions', + declaration: 'export interface RemoteStreamOptions {\n readonly name: string;\n readonly open: (signal: AbortSignal) => AsyncIterable;\n readonly ended: (accepted: boolean) => Error;\n readonly carrierFailed?: (error: RemoteStreamCarrierError) => void;\n}', }, { name: 'ScopeOf', @@ -699,15 +635,31 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionAreaProps', - declaration: 'export interface SessionAreaProps {\n empty?: (() => ReactNode) | undefined;\n children: (sessionId: SessionIdOf) => ReactNode;\n}', + declaration: 'export interface SessionAreaProps {\n empty?: (() => ReactNode) | undefined;\n children: ReactNode;\n}', }, { name: 'SessionBinding', - declaration: 'export interface SessionBinding {\n readonly sessionId: SessionId;\n readonly session: SessionFace;\n readonly ctx: AgentContext;\n}', + declaration: 'export interface SessionBinding {\n readonly sessionId: SessionId;\n readonly session: SessionFace;\n readonly eventSource: SessionEventSource;\n readonly ctx: AgentContext;\n}', + }, + { + name: 'SessionEventChange', + declaration: 'export type SessionEventChange = {\n readonly kind: \'replace\';\n readonly entries: readonly SessionEventEntry[];\n} | {\n readonly kind: \'prepend\';\n readonly entries: readonly SessionEventEntry[];\n} | {\n readonly kind: \'append\';\n readonly entries: readonly SessionEventEntry[];\n};', + }, + { + name: 'SessionEventEntry', + declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n readonly view?: SessionToolView;\n}', + }, + { + name: 'SessionEventSource', + declaration: 'export type SessionEventSource = ObservableSnapshot;', + }, + { + name: 'SessionEventWindow', + declaration: 'export interface SessionEventWindow {\n readonly entries: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n readonly revision: number;\n readonly change: SessionEventChange;\n}', }, { name: 'SessionFace', - declaration: 'export type SessionFace = ISession & ObservableSnapshot;', + declaration: 'export type SessionFace = ISession & ObservableSnapshot;', }, { name: 'SessionIdOf', @@ -725,10 +677,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchResultItem', declaration: 'export interface SessionSearchResultItem {\n sessionId: SessionId;\n snippet: string;\n}', }, + { + name: 'SessionSnapshot', + declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', + }, { name: 'SessionStandardProps', declaration: 'export interface SessionStandardProps {\n}', }, + { + name: 'SessionToolCallView', + declaration: 'export type SessionToolCallView = (Omit & {\n readonly rawInput?: JsonValue;\n}) | TerminalCallView | DiffCallView;', + }, + { + name: 'SessionToolView', + declaration: 'export type SessionToolView = {\n readonly for: \'call\';\n readonly view: SessionToolCallView;\n} | {\n readonly for: \'result\';\n readonly view: ToolResultView;\n};', + }, + { + name: 'SessionWireEvent', + declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}', + }, { name: 'SlotComponent', declaration: 'export type SlotComponent

= (props: P) => ReactNode;', @@ -773,14 +741,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SnapshotSelectorHook', declaration: 'export type SnapshotSelectorHook = (sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S;', }, - { - name: 'SteeringMessageNode', - declaration: 'export interface SteeringMessageNode {\n kind: \'steering\';\n messageId: MessageId;\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}', - }, - { - name: 'StepLocation', - declaration: 'export interface StepLocation {\n readonly turn: number;\n readonly step: number;\n readonly start: SessionEvent<\'step/start\'> | undefined;\n readonly end: SessionEvent<\'step/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly data: ConversationLocationDataStore;\n}', - }, { name: 'StoreDecl', declaration: 'export type StoreDecl = StoreHandle | StoreFactory;', @@ -829,14 +789,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ThemeTokens', declaration: 'export type ThemeTokens = Record;', }, - { - name: 'ToolCallBlock', - declaration: 'export type ToolCallBlock = RunningToolCall | ToolResultNode;', - }, - { - name: 'ToolResultNode', - declaration: 'export interface ToolResultNode {\n kind: \'tool-result\';\n seq: number;\n time: number;\n callId: string;\n call: {\n name: string;\n argsRaw: string;\n } | null;\n callTime: number | null;\n content: readonly ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n callView: ToolCallView | null;\n resultView: ToolResultView | null;\n subCalls: readonly ToolCallBlock[];\n}', - }, { name: 'Translate', declaration: 'export type Translate = (key: K, params?: Record) => string;', @@ -846,24 +798,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export type TranslateNS = Translate>;', }, { - name: 'TurnErrorNode', - declaration: 'export interface TurnErrorNode {\n kind: \'turn-error\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n message: string;\n code?: string;\n}', - }, - { - name: 'TurnLocation', - declaration: 'export interface TurnLocation {\n readonly turn: number;\n readonly start: SessionEvent<\'turn/start\'> | undefined;\n readonly end: SessionEvent<\'turn/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly steps: readonly StepLocation[];\n readonly data: ConversationLocationDataStore;\n}', - }, - { - name: 'TurnMaxTokensNode', - declaration: 'export interface TurnMaxTokensNode {\n kind: \'turn-max-tokens\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n}', - }, - { - name: 'UnknownSurfaceNode', - declaration: 'export interface UnknownSurfaceNode {\n kind: \'unknown\';\n seq: number;\n time: number;\n type: string;\n data: unknown;\n}', - }, - { - name: 'UserMessageNode', - declaration: 'export interface UserMessageNode {\n kind: \'user\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}', + name: 'WorkspaceView', + declaration: 'export interface WorkspaceView {\n readonly workspaceId: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n readonly createdAt: string;\n readonly updatedAt: string;\n}', }, ] @@ -888,7 +824,7 @@ function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] { const next: string[] = [] for (const entry of TYPE_API) { if (included.has(entry.name)) continue - const pattern = new RegExp(`\b${entry.name}\b`) + const pattern = new RegExp(`\\b${entry.name}\\b`) if (!frontier.some(text => pattern.test(text))) continue included.add(entry.name) next.push(entry.declaration) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index bbf2662b4e..e038b24a0d 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -147,7 +147,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.approval.detail\', () => ctx.slots.register(\n { name: \'conversation.approval.detail\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-approval/src/client/contract/slots.ts:25', + source: 'packages/client/ui-approval/src/client/contract/slots.ts:37', }, { key: 'conversation.chat.assistant-actions', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts new file mode 100644 index 0000000000..019d34ac90 --- /dev/null +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { EVENT_API, queryServiceApi, SERVICE_API } from '../src/client/api-catalog.ts' + +describe('Client Cordis inspect catalog', () => { + it('publishes the split Workspace Controller and UI navigation services', () => { + expect(SERVICE_API.find(service => service.key === 'workspaces')?.methods.map(method => method.signature)) + .toEqual([ + 'create(input: { path: string }): Promise', + 'rename(workspaceId: WorkspaceId, title: string): Promise', + 'delete(workspaceId: WorkspaceId): Promise', + 'archiveSession(sessionId: SessionId): Promise', + 'insertSessionBefore( workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId, ): Promise', + ]) + expect(SERVICE_API.find(service => service.key === 'uiWorkspace')?.methods.map(method => method.signature)) + .toEqual([ + 'connectWorkspace(workspaceId: WorkspaceId): Promise', + 'startSession(workspaceId?: WorkspaceId): void', + 'archiveSession(sessionId: SessionId): Promise', + 'pickDirectory(): Promise', + 'listDirectory(path?: string, signal?: AbortSignal): Promise', + 'createDirectory(path: string, name: string): Promise', + 'openPath(path: string): Promise', + ]) + }) + + it('contains one entry per visible Client event', () => { + const names = EVENT_API.map(event => event.name) + expect(new Set(names).size).toBe(names.length) + }) + + it('includes the current referenced type closure for the Sessions service', () => { + const result = queryServiceApi('sessions') as { + referencedTypes: readonly { name: string }[] + } + expect(result.referencedTypes.length).toBeGreaterThan(0) + expect(result.referencedTypes.map(type => type.name)).not.toEqual(expect.arrayContaining([ + 'ConversationSnapshot', + 'PendingInteraction', + 'PendingPayloads', + 'PendingWait', + ])) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index bb3e9918ca..9d0fc17eb8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5660,7 +5660,7 @@ function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] { const next: string[] = [] for (const entry of TYPE_API) { if (included.has(entry.name)) continue - const pattern = new RegExp(`\b${entry.name}\b`) + const pattern = new RegExp(`\\b${entry.name}\\b`) if (!frontier.some(text => pattern.test(text))) continue included.add(entry.name) next.push(entry.declaration) diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 30df8157a8..f4e47ebc62 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -3,7 +3,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { MessageId, type CallId } from './brand.ts' import { deepFreeze } from './call-config.ts' -import type { ContentBlock, StreamChunk, ToolResultBlock } from './types.ts' +import type { ContentBlock, ToolResultBlock } from './types.ts' /** Provider/model identity and adapter-private replay data for an assistant message. */ export interface AssistantProvenance { @@ -240,23 +240,3 @@ export function createToolResultMessage(input: ToolResultMessageInput): ToolResu }], }) } - -/** - * Whether a stream chunk carries visible model output (the first-token - * boundary shared by client step timing and the whole-log sessionStats - * projection). Empty deltas (heartbeats, empty tool-call frames) do not count - * as a first token. - * @param chunk - the stream chunk to test. - * @returns true when the chunk contains a non-empty text/reasoning/tool delta. - */ -export function isTokenDelta(chunk: StreamChunk): boolean { - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return chunk.text !== '' - case 'tool-call-delta': - return chunk.argumentsDelta !== '' || chunk.name !== undefined - default: - return false - } -} diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index 4ab43036c4..dc408fe45a 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -24,9 +24,26 @@ */ import { z } from 'zod' -import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' +import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +/* jscpd:ignore-start -- Session Stats owns its whole-log timing projection independently. */ + +/** Whether a stream chunk carries a non-empty first-token delta. */ +function isTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +/* jscpd:ignore-end */ + /** Accumulated whole-log figures (the view is exactly these totals). */ interface SessionStatsTotals { /** Distinct turns with at least one closed step so far. */ diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts index 63688ce436..839d7aa7d4 100644 --- a/packages/session/session-stats/tests/projection.spec.ts +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -204,6 +204,35 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 })) }) + it('uses non-empty Tool-call names or arguments as the first token', () => { + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', argumentsDelta: '' }, + }), + at(1_200, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' }, + }), + at(2_000, 'assistant/message', { turn: 1, step: 1, message }), + at(2_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 200, ttftSteps: 1 })) + + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_300, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', argumentsDelta: '{' }, + }), + at(2_000, 'assistant/message', { turn: 1, step: 1, message }), + at(2_100, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 300, ttftSteps: 1 })) + }) + it('leaves a cancelled step untimed: counted by step/end, no assembled message to accrue from', () => { expect(fold([ at(1_000, 'step/start', { turn: 1, step: 1 }), diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 2a1df9e944..1bf73ad50a 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -97,7 +97,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ' disposeGraceMs: 500', "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", ' config:', - ' timeoutMs: 20000', + ' timeoutMs: 60000', '', ].join('\n')) @@ -163,5 +163,5 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp const exited = text(await execute('exit', 'exit')) expect(exited).toContain('next pwsh call starts from the workspace') expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root) - }, 60_000) + }, 120_000) }) diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 8ce666b96c..ec3065d94a 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -826,7 +826,7 @@ function renderRuntimeApi( ' const next: string[] = []', ' for (const entry of TYPE_API) {', ' if (included.has(entry.name)) continue', - ' const pattern = new RegExp(`\\b${entry.name}\\b`)', + ' const pattern = new RegExp(`\\\\b${entry.name}\\\\b`)', ' if (!frontier.some(text => pattern.test(text))) continue', ' included.add(entry.name)', ' next.push(entry.declaration)', diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 04314b33ef..92e1899a9f 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/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/util/README.md -README.md: 7867f6c235dfd3cb063214381ac2f40ed0f26ce3 -README.zh.md: 33ca9912f3670dfd3419b7a2a33f462cb4be95a3 +README.md: a4bdee07492b268901a7bc03b416e58bde44e21b +README.zh.md: 247c60dd9dc61d390b39efb50b2d0804cebbe3b7 diff --git a/packages/util/README.md b/packages/util/README.md index 7867f6c235..a4bdee0749 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -12,3 +12,4 @@ These zero-dependency packages provide small primitives shared by multiple capab | [`retention/`](output-retention/README.md) | Bounds retained text and item collections | | [`atomic-write/`](atomic-write/README.md) | Replaces files atomically | | [`native-command/`](native-command/README.md) | Runs host-native commands without a shell | +| [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 33ca9912f3..247c60dd9d 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -12,3 +12,4 @@ | [`retention/`](output-retention/README.zh.md) | 限制保留文本和项集合的大小 | | [`atomic-write/`](atomic-write/README.zh.md) | 以原子方式替换文件 | | [`native-command/`](native-command/README.zh.md) | 不经 shell 运行宿主原生命令 | +| [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器可用的 Workspace 路径与展示辅助函数 | diff --git a/packages/util/crypto/README.i18n.yaml b/packages/util/crypto/README.i18n.yaml index 50e8178e37..03a133b318 100644 --- a/packages/util/crypto/README.i18n.yaml +++ b/packages/util/crypto/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/util/crypto/README.md -README.md: 5c01ed2dbdc6d5639867d848b3a97ccb06b9d347 -README.zh.md: fe8ab5d81eee0faa30d5f09c84ca82e0475b1c91 +README.md: e158680b32ed0507f7120ebef081fe9d633e35a3 +README.zh.md: a5a53be8b9409b2460011d47fff61a6d542f8007 diff --git a/packages/util/crypto/README.md b/packages/util/crypto/README.md index 5c01ed2dbd..e158680b32 100644 --- a/packages/util/crypto/README.md +++ b/packages/util/crypto/README.md @@ -2,18 +2,19 @@ English | [中文](README.zh.md) -Zero-dependency v4 UUID minting over `crypto.getRandomValues` — the one random primitive every shipped context provides. `crypto.randomUUID` is a secure-context Web API: a page or worker served over plain HTTP on a LAN address (the browser preview deployment) has no such method, so code that must run there cannot call it. The repository-wide `no-restricted-properties` lint rule points `crypto.randomUUID` callers here; Node-only code importing `randomUUID` from `node:crypto` stays as it is. +Zero-dependency browser-safe UUID and byte-encoding helpers. UUID minting uses `crypto.getRandomValues`, the one random primitive every shipped context provides. `crypto.randomUUID` is a secure-context Web API: a page or worker served over plain HTTP on a LAN address (the browser preview deployment) has no such method, so code that must run there cannot call it. The repository-wide `no-restricted-properties` lint rule points `crypto.randomUUID` callers here; Node-only code importing `randomUUID` from `node:crypto` stays as it is. It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state. ## API ```ts -import { randomUUID, type Uuid } from '@deepseek-ai/dsh-util-crypto' +import { bytesToBase64, randomUUID, type Uuid } from '@deepseek-ai/dsh-util-crypto' ``` | Export | Role | |---|---| +| `bytesToBase64(data)` | Canonical base64 for a byte array, encoded in bounded chunks. | | `randomUUID()` | Random RFC 9562 v4 UUID string, minted from `crypto.getRandomValues`. Drop-in for `crypto.randomUUID()`. | | `Uuid` | The five-group UUID string type, matching `crypto.randomUUID`'s declared return shape. | diff --git a/packages/util/crypto/README.zh.md b/packages/util/crypto/README.zh.md index fe8ab5d81e..a5a53be8b9 100644 --- a/packages/util/crypto/README.zh.md +++ b/packages/util/crypto/README.zh.md @@ -2,18 +2,19 @@ [English](README.md) | 中文 -零依赖的 v4 UUID 铸造,基于 `crypto.getRandomValues`——所有发布上下文都提供的那个随机原语。`crypto.randomUUID` 是安全上下文限定的 Web API:经普通 HTTP 在局域网地址上提供的页面或 worker(浏览器预览部署)根本没有这个方法,必须在那里运行的代码不能调它。全仓 `no-restricted-properties` lint 规则把 `crypto.randomUUID` 的调用者指到这里;只跑在 Node 的代码从 `node:crypto` 导入 `randomUUID` 维持原样。 +零依赖、可在浏览器使用的 UUID 与字节编码辅助函数。UUID 铸造基于 `crypto.getRandomValues`——所有发布上下文都提供的那个随机原语。`crypto.randomUUID` 是安全上下文限定的 Web API:经普通 HTTP 在局域网地址上提供的页面或 worker(浏览器预览部署)根本没有这个方法,必须在那里运行的代码不能调它。全仓 `no-restricted-properties` lint 规则把 `crypto.randomUUID` 的调用者指到这里;只跑在 Node 的代码从 `node:crypto` 导入 `randomUUID` 维持原样。 它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 ## API ```ts -import { randomUUID, type Uuid } from '@deepseek-ai/dsh-util-crypto' +import { bytesToBase64, randomUUID, type Uuid } from '@deepseek-ai/dsh-util-crypto' ``` | 导出 | 角色 | |---|---| +| `bytesToBase64(data)` | 以有界分片把字节数组编码为标准 base64。 | | `randomUUID()` | 随机 RFC 9562 v4 UUID 字符串,由 `crypto.getRandomValues` 铸造。可原位替换 `crypto.randomUUID()`。 | | `Uuid` | 五段式 UUID 字符串类型,与 `crypto.randomUUID` 声明的返回形状一致。 | diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 88a3bca3ed..097bc5cdaf 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-util-crypto", - "description": "Zero-dependency crypto-adjacent helpers usable in every context (browser, worker, Node) including insecure origins: v4 UUID minting over crypto.getRandomValues, with room for sibling helpers", + "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" diff --git a/packages/util/crypto/src/index.ts b/packages/util/crypto/src/index.ts index afde4fe67c..cfb55b57a5 100644 --- a/packages/util/crypto/src/index.ts +++ b/packages/util/crypto/src/index.ts @@ -12,6 +12,20 @@ /** RFC 9562 UUID string, the shape `crypto.randomUUID` declares. */ export type Uuid = `${string}-${string}-${string}-${string}-${string}` +/** + * Encode bytes as canonical base64 without overflowing function argument limits. + * @param data - Bytes to encode. + * @returns base64 text. + */ +export function bytesToBase64(data: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let offset = 0; offset < data.length; offset += chunk) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) + } + return btoa(binary) +} + /** * Random v4 UUID, minted from `crypto.getRandomValues`. * @returns the UUID string. diff --git a/packages/util/crypto/tests/uuid.spec.ts b/packages/util/crypto/tests/uuid.spec.ts index 9a2f261648..073b73bcb5 100644 --- a/packages/util/crypto/tests/uuid.spec.ts +++ b/packages/util/crypto/tests/uuid.spec.ts @@ -4,7 +4,7 @@ * `crypto.randomUUID` — the reason this package exists. */ import { describe, expect, it, vi } from 'vitest' -import { randomUUID } from '../src/index.ts' +import { bytesToBase64, randomUUID } from '../src/index.ts' const V4_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ @@ -27,3 +27,11 @@ describe('randomUUID', () => { } }) }) + +describe('bytesToBase64', () => { + it('encodes empty, binary, and multi-chunk byte arrays', () => { + expect(bytesToBase64(new Uint8Array())).toBe('') + expect(bytesToBase64(new Uint8Array([0, 127, 128, 255]))).toBe('AH+A/w==') + expect(bytesToBase64(new Uint8Array(0x8001).fill(65))).toBe('QUFB'.repeat(10923)) + }) +}) diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml new file mode 100644 index 0000000000..31d6dc2d2f --- /dev/null +++ b/packages/util/workspace-path/README.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 packages/util/workspace-path/README.md +README.md: 2777a3d32387a414aa1b2f406dc3a1f4900e7f43 +README.zh.md: 708c0e7c367fd8420e3732ab4f356c1200e6bce1 diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md new file mode 100644 index 0000000000..2777a3d323 --- /dev/null +++ b/packages/util/workspace-path/README.md @@ -0,0 +1,10 @@ +# dsh-util-workspace-path + +English | [中文](README.zh.md) + +Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, and derives Workspace titles from POSIX or Windows paths. It has no Cordis service or runtime state. + +## Known Limitations and Deferred Work + +- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths but does not access a filesystem or canonicalize `.` and `..` segments. +- **Home abbreviation is POSIX-only** — Windows paths remain unchanged because a portable browser cannot infer Windows home-path equivalence safely. diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md new file mode 100644 index 0000000000..708c0e7c36 --- /dev/null +++ b/packages/util/workspace-path/README.zh.md @@ -0,0 +1,10 @@ +# dsh-util-workspace-path + +[English](README.md) | 中文 + +供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录,以及从 POSIX 或 Windows 路径提取 Workspace 标题;它不提供 Cordis service,也不持有运行时状态。 + +## 已知限制与暂缓事项 + +- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。 +- **主目录缩写仅支持 POSIX**——Windows 路径保持不变,因为可移植浏览器无法安全推断 Windows 主目录路径等价关系。 diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json new file mode 100644 index 0000000000..c1572fe690 --- /dev/null +++ b/packages/util/workspace-path/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-util-workspace-path", + "description": "Browser-safe Workspace path and display helpers", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/workspace-path" + }, + "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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/util/workspace-path/src/index.ts b/packages/util/workspace-path/src/index.ts new file mode 100644 index 0000000000..e2f57276f3 --- /dev/null +++ b/packages/util/workspace-path/src/index.ts @@ -0,0 +1,51 @@ +/** + * Browser-safe Workspace path and display helpers. + * @module @deepseek-ai/dsh-util-workspace-path + */ + +/** Whether a path uses a Windows drive or UNC prefix. */ +function isWindowsStylePath(value: string): boolean { + return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') +} + +/** + * Resolve a Workspace-relative path into the Host-facing spelling used by path operations. + * @param cwd - Session Workspace root, when known. + * @param path - Absolute or Workspace-relative path. + * @returns an absolute path when a Workspace root is available, otherwise the original path. + */ +export function resolveWorkspacePath(cwd: string | undefined, path: string): string { + if (path.startsWith('/') || isWindowsStylePath(path)) return path + if (cwd === undefined || cwd === '') return path + const base = cwd.replace(/[/\\]+$/, '') + const relative = path.replace(/^[/\\]+/, '') + return `${base}/${relative}` +} + +/** + * Abbreviate a POSIX home directory for display. + * @param path - Absolute or already-short display path. + * @param home - Host account home; absent skips abbreviation. + * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. + */ +export function abbreviateHomePath(path: string, home?: string): string { + if (home === undefined || home === '') return path + if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path + const root = home.replace(/\/+$/, '') + if (root === '' || root === '/') return path + if (path.replace(/\/+$/, '') === root) return '~' + if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}` + return path +} + +/** + * Read the final non-empty segment of a Workspace path for display. + * Workspace-label surfaces use this helper instead of deriving another basename. + * @param path - Workspace directory path using POSIX or Windows separators. + * @returns the final segment, or an empty string for a separator-only path. + */ +export function workspaceTitleOf(path: string): string { + const trimmed = path.replace(/[/\\]+$/, '') + const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + return trimmed.slice(separator + 1) +} diff --git a/packages/util/workspace-path/src/invariant.ts b/packages/util/workspace-path/src/invariant.ts new file mode 100644 index 0000000000..7cac9479dd --- /dev/null +++ b/packages/util/workspace-path/src/invariant.ts @@ -0,0 +1,27 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-util-workspace-path`. + * @module @deepseek-ai/dsh-util-workspace-path/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-util-workspace-path' + +/** Cordis companion plugin name. */ +export const name = 'workspace-path-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this utility owns no mutable runtime relationship. */ +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/util/workspace-path/tests/index.spec.ts b/packages/util/workspace-path/tests/index.spec.ts new file mode 100644 index 0000000000..7dccbe4530 --- /dev/null +++ b/packages/util/workspace-path/tests/index.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + abbreviateHomePath, resolveWorkspacePath, workspaceTitleOf, +} from '@deepseek-ai/dsh-util-workspace-path' + +describe('Workspace path helpers', () => { + it('resolves relative paths without changing absolute paths', () => { + expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') + expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') + expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('', 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') + expect(resolveWorkspacePath('/w', '\\\\server\\share')).toBe('\\\\server\\share') + }) + + it('abbreviates only descendants of a POSIX home', () => { + expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project') + expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts') + expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts') + expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project') + expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')) + .toBe('\\\\server\\share\\u') + }) + + it('reads the final path segment on both path styles', () => { + expect(workspaceTitleOf('/work/project/')).toBe('project') + expect(workspaceTitleOf('C:\\work\\project\\')).toBe('project') + expect(workspaceTitleOf('/')).toBe('') + }) +}) diff --git a/packages/util/workspace-path/tsconfig.json b/packages/util/workspace-path/tsconfig.json new file mode 100644 index 0000000000..779effc3cc --- /dev/null +++ b/packages/util/workspace-path/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601785a90f..a30fa4f902 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1071,6 +1071,9 @@ importers: '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -2164,6 +2167,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2325,6 +2334,9 @@ importers: '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -3539,6 +3551,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@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) @@ -3603,6 +3618,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3791,6 +3809,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -9113,6 +9134,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/workspace-path: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/web/tool-web: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 8eb9886503..d9222f617b 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -100,9 +100,9 @@ describe('client bundle purity gate', () => { }) it('admits package-specific requests only for the declaring bundle', () => { - expect(resolveId('@deepseek-ai/dsh-api-session-controller/client')).toBeNull() - const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal') - expect(() => withoutRequest('@deepseek-ai/dsh-api-session-controller/client')).toThrow(/purity/) + const requesting = purityResolveId('@deepseek-ai/dsh-api-session-controller') + expect(requesting('@deepseek-ai/dsh-api-gateway/client')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-api-gateway/client')).toThrow(/purity/) }) it('externalizes the baseline independently of each package manifest', () => { diff --git a/scripts/gen-cordis-inspect-catalog.ts b/scripts/gen-cordis-inspect-catalog.ts index 77e6404c4d..24ce8d70d2 100644 --- a/scripts/gen-cordis-inspect-catalog.ts +++ b/scripts/gen-cordis-inspect-catalog.ts @@ -1,6 +1,6 @@ /** Generate model-visible Host/Client Service and Event inspect catalogs. */ -import { mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { projectCordisCatalog } from '@deepseek-ai/dsh-typert-generator' import type { CordisCatalogModel, ServiceMethodEntry } from '@deepseek-ai/dsh-typert-generator' @@ -15,10 +15,11 @@ const CLIENT_SERVICES: Readonly> = { sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'], slots: ['register', 'inject'], theme: ['getTheme', 'setTheme', 'register', 'overrideTokens'], - workspaces: [ - 'connectWorkspace', 'startSession', 'create', 'pickDirectory', 'listDirectory', 'createDirectory', - 'openPath', 'rename', 'delete', 'insertSessionBefore', 'archiveSession', + uiWorkspace: [ + 'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory', + 'createDirectory', 'openPath', ], + workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession'], } const CLIENT_EVENTS = new Set([ @@ -33,14 +34,34 @@ function methodName(method: ServiceMethodEntry): string | undefined { } function clientModel(model: CordisCatalogModel): CordisCatalogModel { + const services = Object.entries(CLIENT_SERVICES).map(([key, allowed]) => { + const matches = model.services.filter(service => service.key === key) + if (matches.length !== 1) { + throw new Error(`gen-cordis-inspect-catalog: expected one Client Service ${JSON.stringify(key)}, found ${matches.length}`) + } + const service = matches[0] as (typeof matches)[number] + const names = new Set(allowed) + const methods = service.methods.filter(method => names.has(methodName(method) ?? '')) + const found = new Set(methods.map(method => methodName(method))) + const missing = allowed.filter(name => !found.has(name)) + if (missing.length > 0) { + throw new Error( + `gen-cordis-inspect-catalog: Client Service ${JSON.stringify(key)}` + + ` is missing allowlisted method(s): ${missing.join(', ')}`, + ) + } + return { ...service, methods } + }) + const events = [...CLIENT_EVENTS].map((name) => { + const matches = model.events.filter(event => event.name === name) + if (matches.length !== 1) { + throw new Error(`gen-cordis-inspect-catalog: expected one Client Event ${JSON.stringify(name)}, found ${matches.length}`) + } + return matches[0] as (typeof matches)[number] + }) return { - services: model.services.flatMap((service) => { - const allowed = CLIENT_SERVICES[service.key] - if (allowed === undefined) return [] - const names = new Set(allowed) - return [{ ...service, methods: service.methods.filter(method => names.has(methodName(method) ?? '')) }] - }), - events: model.events.filter(event => CLIENT_EVENTS.has(event.name)), + services, + events, } } @@ -48,7 +69,17 @@ function main(): void { const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY, 'client') const destination = resolve(root, CLIENT_OUT) const source = projector.renderRuntimeApi(clientModel(model)) + .replace('Generated by scripts/gen-cordis-api.ts', 'Generated by scripts/gen-cordis-inspect-catalog.ts') + .replaceAll('pnpm run gen-cordis-api', 'pnpm run gen-cordis-inspect-catalog') + .replaceAll('pnpm run verify-cordis-api', 'pnpm run verify-cordis-inspect-catalog') .replaceAll('@deepseek-ai/dsh-tool-cordis/api-catalog', '@deepseek-ai/dsh-cordis-client-runner/client/api-catalog') + if (process.argv.includes('--check')) { + if (!existsSync(destination) || readFileSync(destination, 'utf8') !== source) { + throw new Error(`gen-cordis-inspect-catalog: ${CLIENT_OUT} is stale; run pnpm run gen-cordis-inspect-catalog`) + } + console.log(`gen-cordis-inspect-catalog: ${CLIENT_OUT} is up to date`) + return + } mkdirSync(dirname(destination), { recursive: true }) writeFileSync(destination, source) console.log(`gen-cordis-inspect-catalog: wrote ${CLIENT_OUT}`) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 05ff7356f1..df1c4dc4a4 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -377,7 +377,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(43) + expect(translated).toHaveLength(46) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks).toEqual([]) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 96354d870d..e7188f7709 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -110,7 +110,7 @@ describe('gate graph validation', () => { expect(ids.slice(0, 10)).toEqual([ 'doc-typecheck', 'docs-site-build', 'doc-graphs', 'markdown-links', 'type-equivalence', - 'cordis-catalog', 'mermaid', 'scoped-events', 'translation-pairing', 'markdown-wrap', + 'cordis-catalog', 'cordis-inspect-catalog', 'mermaid', 'scoped-events', 'translation-pairing', ]) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 8b5fd2a879..e24e662153 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -665,6 +665,7 @@ function docSyncLeafGates(options: { pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), + pnpmScript('cordis-inspect-catalog', 'verify-cordis-inspect-catalog', { label: 'Cordis inspect catalog' }), pnpmScript('mermaid', 'verify-mermaid'), pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index df12891476..aa39b84316 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { collectClientPackageViolations, collectRuntimeSourcePackageUses, + collectRuntimeSourceSpecifiers, collectSourcePackageUses, fixClientPackageManifests, readClientDeclarations, @@ -32,6 +33,8 @@ function declaration( dynamic: true, external: [], inject: [], + runtimeSourceUses: {}, + runtimeSourceSpecifiers: {}, ...fields, } } @@ -75,7 +78,7 @@ describe('source package uses', () => { const uses = collectSourcePackageUses('feature.tsx', [ "import type { A } from '@deepseek-ai/dsh-a/subpath'", "declare module '@deepseek-ai/dsh-client-ui-slots' {}", - "const load = () => import('@deepseek-ai/dsh-b')", + "const load = () => import('@deepseek-ai/dsh-b/remote')", 'export const view =

', "export type { Local } from './local.ts'", ].join('\n')) @@ -95,6 +98,14 @@ describe('source package uses', () => { '@deepseek-ai/dsh-b', 'react', ]) + expect([...collectRuntimeSourceSpecifiers('feature.tsx', [ + "import type { A } from '@deepseek-ai/dsh-a/subpath'", + "const load = () => import('@deepseek-ai/dsh-b/remote')", + 'export const view =
', + ].join('\n'))].sort()).toEqual([ + '@deepseek-ai/dsh-b/remote', + 'react', + ]) }) }) @@ -245,10 +256,61 @@ describe('dependency sections', () => { }) describe('module requests', () => { - it('accepts a dynamic row supplier and its client subpath', () => { - const ui = declaration('ui', { external: ['@deepseek-ai/dsh-client-slots/client'] }) + it('rejects runtime requests from one client feature package to another dynamic row', () => { + const ui = declaration('ui', { + external: ['@deepseek-ai/dsh-client-slots/client'], + runtimeSourceUses: { + '@deepseek-ai/dsh-client-slots': ['packages/client/ui/src/client/index.ts'], + }, + }) const slots = declaration('slots') - expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([]) + expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([ + ui.manifest + ': client feature package requests runtime external ' + + '"@deepseek-ai/dsh-client-slots/client"; import shared types only or call an injected Cordis service', + ]) + }) + + it('rejects stale externals and accepts a runtime import outside client feature packages', () => { + const gateway = { + ...declaration('@deepseek-ai/dsh-api-gateway'), manifest: 'packages/api/gateway/package.json', + } + const stale = { ...declaration('@deepseek-ai/dsh-api-stale', { + external: ['@deepseek-ai/dsh-api-gateway/client'], + }), manifest: 'packages/api/stale/package.json' } + const live = { ...declaration('@deepseek-ai/dsh-api-live', { + external: ['@deepseek-ai/dsh-api-gateway/client'], + runtimeSourceUses: { + '@deepseek-ai/dsh-api-gateway': ['packages/api/live/src/client/index.ts'], + }, + runtimeSourceSpecifiers: { + '@deepseek-ai/dsh-api-gateway/client': ['packages/api/live/src/client/index.ts'], + }, + }), manifest: 'packages/api/live/package.json' } + expect(collectClientPackageViolations(facts([], { + declarations: [gateway, stale, live], + }))).toEqual([ + stale.manifest + ': dsh.client.external "@deepseek-ai/dsh-api-gateway/client"' + + ' has no runtime import or re-export in production source; remove the stale declaration', + ]) + }) + + it('requires the exact external subpath to be imported at runtime', () => { + const gateway = { + ...declaration('@deepseek-ai/dsh-api-gateway'), manifest: 'packages/api/gateway/package.json', + } + const subject = { ...declaration('@deepseek-ai/dsh-api-session-controller', { + external: ['@deepseek-ai/dsh-api-gateway/client'], + runtimeSourceUses: { + '@deepseek-ai/dsh-api-gateway': ['packages/api/session-controller/src/client/index.ts'], + }, + runtimeSourceSpecifiers: { + '@deepseek-ai/dsh-api-gateway/remote': ['packages/api/session-controller/src/client/index.ts'], + }, + }), manifest: 'packages/api/session-controller/package.json' } + expect(collectClientPackageViolations(facts([], { declarations: [gateway, subject] }))).toEqual([ + subject.manifest + ': dsh.client.external "@deepseek-ai/dsh-api-gateway/client"' + + ' has no runtime import or re-export in production source; remove the stale declaration', + ]) }) it('rejects an explicit baseline request', () => { @@ -275,14 +337,18 @@ describe('module requests', () => { }) it('rejects synchronous module-request cycles but ignores inject cycles', () => { - const a = declaration('a', { - external: ['@deepseek-ai/dsh-client-b'], - inject: ['@deepseek-ai/dsh-client-b'], - }) - const b = declaration('b', { - external: ['@deepseek-ai/dsh-client-a'], - inject: ['@deepseek-ai/dsh-client-a'], - }) + const a = { ...declaration('@deepseek-ai/dsh-api-a', { + external: ['@deepseek-ai/dsh-api-b'], + inject: ['@deepseek-ai/dsh-api-b'], + runtimeSourceUses: { '@deepseek-ai/dsh-api-b': ['packages/api/a/src/client.ts'] }, + runtimeSourceSpecifiers: { '@deepseek-ai/dsh-api-b': ['packages/api/a/src/client.ts'] }, + }), manifest: 'packages/api/a/package.json' } + const b = { ...declaration('@deepseek-ai/dsh-api-b', { + external: ['@deepseek-ai/dsh-api-a'], + inject: ['@deepseek-ai/dsh-api-a'], + runtimeSourceUses: { '@deepseek-ai/dsh-api-a': ['packages/client/b/src/client.ts'] }, + runtimeSourceSpecifiers: { '@deepseek-ai/dsh-api-a': ['packages/client/b/src/client.ts'] }, + }), manifest: 'packages/api/b/package.json' } const found = collectClientPackageViolations(facts([], { declarations: [a, b] })) expect(found).toHaveLength(1) expect(found[0]).toContain('synchronous dsh.client.external cycle') diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index 225f944bdf..294cba6328 100644 --- a/scripts/verify-client-packages.ts +++ b/scripts/verify-client-packages.ts @@ -26,6 +26,9 @@ export interface ClientDeclaration { readonly manifest: string readonly dynamic: boolean readonly external: readonly string[] + readonly runtimeSourceUses: Readonly> + /** Exact runtime specifiers used to validate `dsh.client.external` declarations. */ + readonly runtimeSourceSpecifiers: Readonly> /** Informational package dependencies declared by the row. */ readonly inject: readonly string[] } @@ -34,7 +37,6 @@ export interface ClientDeclaration { export interface ClientPackage extends ClientDeclaration { readonly staticLinked: boolean readonly sourceUses: Readonly> - readonly runtimeSourceUses: Readonly> readonly dependencies: Readonly> readonly peerDependencies: Readonly> readonly devDependencies: Readonly> @@ -65,7 +67,7 @@ export interface ClientDeclarations { */ export function collectSourcePackageUses(path: string, source: string): Set { const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) - return collectSourceFilePackageUses(sourceFile, false) + return collectSourceFileUses(sourceFile, false, 'package') } /** @@ -76,7 +78,18 @@ export function collectSourcePackageUses(path: string, source: string): Set { const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) - return collectSourceFilePackageUses(sourceFile, true) + return collectSourceFileUses(sourceFile, true, 'package') +} + +/** + * Collect exact bare specifiers retained by one production source file. + * @param path - File path used to select TypeScript's parser mode. + * @param source - Source text to inspect. + * @returns Exact specifiers retained by runtime imports, exports, requires, or JSX. + */ +export function collectRuntimeSourceSpecifiers(path: string, source: string): Set { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + return collectSourceFileUses(sourceFile, true, 'specifier') } function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean { @@ -98,12 +111,16 @@ function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean { return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly) } -function collectSourceFilePackageUses(sourceFile: ts.SourceFile, runtimeOnly: boolean): Set { +function collectSourceFileUses( + sourceFile: ts.SourceFile, + runtimeOnly: boolean, + key: 'package' | 'specifier', +): Set { const uses = new Set() const add = (specifier: ts.Expression | undefined): void => { if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return - uses.add(packageNameOf(specifier.text)) + uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text) } const visit = (node: ts.Node): void => { if (ts.isImportDeclaration(node)) { @@ -564,6 +581,20 @@ function collectModuleViolations(facts: ClientPackageFacts): string[] { if (supplier === pkg.name) { violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier)) } else if (supplier !== undefined) { + if (pkg.manifest.startsWith('packages/client/')) { + violations.push( + pkg.manifest + ': client feature package requests runtime external ' + JSON.stringify(specifier) + + '; import shared types only or call an injected Cordis service', + ) + continue + } + if (pkg.runtimeSourceSpecifiers[specifier] === undefined) { + violations.push( + pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + + ' has no runtime import or re-export in production source; remove the stale declaration', + ) + continue + } edges.push({ from: pkg.name, to: supplier, specifier }) } else { const owner = stripClientSuffix(specifier) @@ -654,11 +685,17 @@ function readDeclaration( const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined const rawClient = dsh?.client if (rawClient === undefined) { - return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] } + return { + name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [], + runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, + } } if (!isRecord(rawClient)) { malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object') - return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] } + return { + name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [], + runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, + } } return { name: manifest.name, @@ -666,6 +703,8 @@ function readDeclaration( dynamic: true, external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed), inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed), + runtimeSourceUses: {}, + runtimeSourceSpecifiers: {}, } } @@ -747,10 +786,42 @@ function readStringLiteralArray(root: string, sourcePath: string, name: string): } async function readFacts(root: string): Promise { - const { declarations, malformed } = readClientDeclarations(root) - const byManifest = new Map(declarations.map(entry => [entry.manifest, entry])) + const { declarations: bareDeclarations, malformed } = readClientDeclarations(root) const staticLinkedPackages = await readStaticLinkedRoster(root) const project = new TypeScriptProject(root, 'client') + const sourceFiles = project.sourceFiles() + const declarations = bareDeclarations.map((declaration): ClientDeclaration => { + const runtimeSourceUses = new Map>() + const runtimeSourceSpecifiers = new Map>() + const sourcePrefix = dirname(declaration.manifest) + '/src/' + for (const sourceFile of sourceFiles) { + if (sourceFile.isDeclarationFile) continue + const file = project.relativePath(sourceFile) + if (!file.startsWith(sourcePrefix)) continue + for (const name of collectSourceFileUses(sourceFile, true, 'package')) { + const locations = runtimeSourceUses.get(name) ?? new Set() + locations.add(file) + runtimeSourceUses.set(name, locations) + } + for (const specifier of collectSourceFileUses(sourceFile, true, 'specifier')) { + const locations = runtimeSourceSpecifiers.get(specifier) ?? new Set() + locations.add(file) + runtimeSourceSpecifiers.set(specifier, locations) + } + } + return { + ...declaration, + runtimeSourceUses: Object.fromEntries( + [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right)) + .map(([name, locations]) => [name, [...locations].sort()]), + ), + runtimeSourceSpecifiers: Object.fromEntries( + [...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right)) + .map(([specifier, locations]) => [specifier, [...locations].sort()]), + ), + } + }) + const byManifest = new Map(declarations.map(entry => [entry.manifest, entry])) const packages: ClientPackage[] = [] for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) { @@ -762,16 +833,16 @@ async function readFacts(root: string): Promise { const runtimeSourceUses = new Map>() const packageDirectory = dirname(manifestPath) const sourcePrefix = packageDirectory + '/src/' - for (const sourceFile of project.sourceFiles()) { + for (const sourceFile of sourceFiles) { if (sourceFile.isDeclarationFile) continue const file = project.relativePath(sourceFile) if (!file.startsWith(sourcePrefix)) continue - for (const name of collectSourceFilePackageUses(sourceFile, false)) { + for (const name of collectSourceFileUses(sourceFile, false, 'package')) { const locations = sourceUses.get(name) ?? new Set() locations.add(file) sourceUses.set(name, locations) } - for (const name of collectSourceFilePackageUses(sourceFile, true)) { + for (const name of collectSourceFileUses(sourceFile, true, 'package')) { const locations = runtimeSourceUses.get(name) ?? new Set() locations.add(file) runtimeSourceUses.set(name, locations) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c6cabd41e6..1fb184af82 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -34,6 +34,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', 'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', 'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', + 'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.', } /** diff --git a/tsconfig.base.json b/tsconfig.base.json index 195cb5d398..2c64da3ce6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -70,7 +70,9 @@ "@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"], "@deepseek-ai/dsh-session-title/types": ["./packages/session/session-title/src/types.ts"], "@deepseek-ai/dsh-session-title/client": ["./packages/session/session-title/src/client.ts"], + "@deepseek-ai/dsh-subagent/client": ["./packages/subagent/subagent/src/client.ts"], "@deepseek-ai/dsh-workspace/types": ["./packages/workspace/workspace/src/types.ts"], + "@deepseek-ai/dsh-util-workspace-path": ["./packages/util/workspace-path/src/index.ts"], "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"], "@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 35258110d5..b64992cdf1 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -131,6 +131,7 @@ { "path": "./packages/util/home-paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/crypto" }, + { "path": "./packages/util/workspace-path" }, { "path": "./packages/util/output-retention" }, { "path": "./packages/util/atomic-write" }, { "path": "./packages/attachment/attachment" }, diff --git a/website/docs.ts b/website/docs.ts index 5e6226ccf0..ed7fd81f4a 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -304,8 +304,11 @@ const subsystemGroups = [ ]], ['平台与接入', 'Platform and access', [ ['web-server.md', 'HTTP 服务器', 'HTTP server'], - ['typert.md', 'Typert', 'Typert'], + ['web-client.md', 'Web Client 架构', 'Web Client architecture'], ['client-modules.md', '客户端模块', 'Client modules'], + ['slots.md', '客户端 Slots', 'Client slots'], + ['conversation.md', 'Conversation 组装', 'Conversation assembly'], + ['typert.md', 'Typert', 'Typert'], ['storage.md', '存储', 'Storage'], ['workspace.md', '工作区', 'Workspaces'], ['settings.md', '用户设置', 'User settings'], @@ -344,6 +347,7 @@ const reference = [ ['docs/capability-seams.md', 'reference/capability-seams.md', '能力服务', 'Capability services', 2], ['docs/agent-lifecycle.md', 'reference/agent-lifecycle.md', 'Agent 生命周期', 'Agent lifecycle', 3], ['docs/tool-execution-pipeline.md', 'reference/tool-execution-pipeline.md', 'Tool 执行', 'Tool execution', 4], + ['docs/api-gateway.md', 'reference/api-gateway.md', 'API Gateway', 'API Gateway', 5], ] as const).map(([source, route, rootLabel, enLabel, order]): PairedPage => ({ source, route, @@ -404,14 +408,6 @@ const reference = [ section: { root: '开发手册', en: 'Cookbook' }, order, }))), - ...pairedPages([{ - source: 'docs/cookbook/adding-a-conversation-node.md', - route: 'reference/cookbook/adding-a-conversation-node.md', - label: { root: '新增 Conversation Node', en: 'Adding a Conversation Node' }, - sidebar: { root: 'zh-reference', en: 'en-reference' }, - section: { root: '开发手册', en: 'Cookbook' }, - order: 5, - }]), ] /**