refactor(web): consume owner remote events directly
This commit is contained in:
@@ -12,17 +12,17 @@ This package contains no transport or Host service discovery logic. Its Client f
|
||||
|
||||
## Forwarded Host events
|
||||
|
||||
`src/types.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it.
|
||||
`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`; the type-only `src/types.ts` derives its selection face. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it.
|
||||
|
||||
The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-commands`, `dsh-credentials`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way.
|
||||
The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-agent-presets`, `dsh-commands`, `dsh-credentials`, `dsh-llm`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way.
|
||||
|
||||
## Build boundary
|
||||
|
||||
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
|
||||
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/types.ts` is listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift.
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift.
|
||||
|
||||
That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit that one source file into their own program and each emits its own `lib/types/types.js`/`.d.ts` over the other's. The two emissions are byte-identical because they compile the same source, and the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph is the only thing standing between the next reader and the conclusion that the double listing is a mistake.
|
||||
That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional.
|
||||
|
||||
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
|
||||
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
|
||||
## 转发的 Host 事件
|
||||
|
||||
`src/types.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。
|
||||
`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。
|
||||
|
||||
监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-commands`、`dsh-credentials`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。
|
||||
监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets`、`dsh-commands`、`dsh-credentials`、`dsh-llm`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。
|
||||
|
||||
## 构建边界
|
||||
|
||||
仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
|
||||
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts` 与 `src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。
|
||||
|
||||
这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把这一个源文件收进各自的 program,各自把 `lib/types/types.js`/`.d.ts` 覆写到对方的输出上。两次 emit 编译的是同一份源码,内容逐字相同,而 `.tsbuildinfo` 各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此挡在下一个读者与「这个双列是写错了」这个结论之间的,只有这一段文字。
|
||||
这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。
|
||||
|
||||
|
||||
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
@@ -77,6 +79,8 @@
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
|
||||
@@ -14,6 +14,8 @@ export type { ApiRemoteForwardedEvent } from '../types.ts'
|
||||
// declaration the Host emits rather than a flattened restatement of it.
|
||||
export type {} from '@deepseek-ai/dsh-commands/types'
|
||||
export type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
export type {} from '@deepseek-ai/dsh-llm/types'
|
||||
export type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
export type {} from '@deepseek-ai/dsh-settings/types'
|
||||
/**
|
||||
* The Gateway Client face's own declaration merges, type-only: `ctx.remote` and
|
||||
|
||||
@@ -9,6 +9,8 @@ import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
|
||||
// event vocabulary.
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
import type {} from '@deepseek-ai/dsh-llm/types'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
|
||||
export {
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
* else.
|
||||
*/
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'llm/adapters-updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
## Slot declaration injection
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
## Slot 声明注入
|
||||
|
||||
@@ -157,24 +157,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
* @param key - the mutated SlotMap key.
|
||||
*/
|
||||
'slots/changed'(key: string): void
|
||||
/**
|
||||
* The host provider topology changed (host/models-changed passthrough).
|
||||
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
* @mode emit
|
||||
*/
|
||||
'models/changed'(): void
|
||||
/**
|
||||
* One session's agent preset changed (host/session-preset-changed
|
||||
* passthrough), so everything its composition decides — the command
|
||||
* catalog, the skill catalog — is stale for that session and no other.
|
||||
* Every connected client observes it, not only the one that issued the
|
||||
* switch. Subscribers refetch their own session-keyed caches; the frame
|
||||
* carries no catalog.
|
||||
* @mode emit
|
||||
* @param sessionId - the session whose composition changed.
|
||||
* @param agentPreset - the preset it now runs.
|
||||
*/
|
||||
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
|
||||
/**
|
||||
* A connection generation was (re-)established. Wire-derived caches must
|
||||
* treat their state as stale and repull (commands directory; the queue
|
||||
@@ -231,10 +213,6 @@ export function apply(ctx: Context): void {
|
||||
// `ctx.remote.$on` subscribers; no consumer reads a frame.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
|
||||
@@ -800,14 +800,6 @@ export class SessionManager {
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-preset-changed': {
|
||||
// Every connected client observes the switch here; only the tab that
|
||||
// issued it also gets the RPC echo. The merge keeps the row's own
|
||||
// updatedAt and lowers `blank` only, so re-applying the switching
|
||||
// tab's own frame is a no-op.
|
||||
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
|
||||
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
|
||||
|
||||
@@ -87,23 +87,6 @@ describe('list store projection', () => {
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
|
||||
// Every connected client gets this frame; only the switching tab gets the
|
||||
// RPC echo. A client that ignored the payload would keep labelling the
|
||||
// session with the composition it replaced.
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
|
||||
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
|
||||
* api-gateway's own coverage);
|
||||
* host/session-preset-changed → ctx 'session/preset-changed';
|
||||
* `host/models-changed` still broadcasts the typed `models/changed`; each
|
||||
* established connection generation → ctx 'connection/reset' (the forced
|
||||
* cache-invalidation broadcast).
|
||||
* api-gateway's own coverage); each established connection generation emits
|
||||
* `connection/reset` for generation-scoped cache invalidation.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -33,6 +30,10 @@ function forwardedEventContracts(ctx: Context): void {
|
||||
})
|
||||
ctx.remote.$on('credentials/updated', () => {})
|
||||
ctx.remote.$on('commands/change', () => {})
|
||||
ctx.remote.$on('llm/adapters-updated', () => {})
|
||||
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
|
||||
void sessionId; void agentPreset
|
||||
})
|
||||
// @ts-expect-error -- client-local event outside the allowlist
|
||||
ctx.remote.$on('slots/changed', () => {})
|
||||
// @ts-expect-error -- declared host event the allowlist does not select
|
||||
@@ -117,25 +118,6 @@ describe('wire event bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('still broadcasts the typed models/changed invalidation (its host frame is unchanged)', async () => {
|
||||
const bench = await mount()
|
||||
let models = 0
|
||||
bench.ctx.on('models/changed', () => { models++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r6' as never, payload: { type: 'host/models-changed' } })
|
||||
expect(models).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
|
||||
})
|
||||
expect(seen).toEqual([['s1', 'minimal']])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
@@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen
|
||||
|
||||
## The session-header label
|
||||
|
||||
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads.
|
||||
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary and resolves the display name against the same roster the General row reads. Forwarded `agent-preset/selected` owner events fold committed blank-session switches into that shared summary in every tab; the initiating tab may already have applied the RPC echo, and the merge is idempotent.
|
||||
|
||||
## What it reads and writes
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
|
||||
|
||||
## 会话标题旁的标签
|
||||
|
||||
第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。
|
||||
第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset,并在 General 行所读的同一份名单上解析显示名称。转发的 owner 事件 `agent-preset/selected` 会在每个标签页中把已经提交的空会话切换折进这份共享摘要;发起方标签页可能已经采用 RPC 回执,而合并是幂等的。
|
||||
|
||||
## 它读什么、写什么
|
||||
|
||||
|
||||
@@ -141,6 +141,11 @@ export function apply(ctx: ClientContext): void {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void seat.load()
|
||||
})
|
||||
// Every tab folds the committed preset into the shared session row; the
|
||||
// initiating tab may already have applied the RPC echo, which is idempotent.
|
||||
const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
|
||||
scope.sessions.noteAgentPreset(sessionId, agentPreset)
|
||||
})
|
||||
// Authoring writes a FILE, not a setting, so nothing on the wire
|
||||
// announces it — without this the screen that starts the next session
|
||||
// keeps offering the roster as it stood when the chip first loaded, and
|
||||
@@ -173,6 +178,7 @@ export function apply(ctx: ClientContext): void {
|
||||
return () => {
|
||||
stop()
|
||||
settingsMoved()
|
||||
presetSelected()
|
||||
rosterReaders.delete(readRoster)
|
||||
creatorDraft = undefined
|
||||
chip()
|
||||
|
||||
@@ -165,6 +165,12 @@ function sessionsDouble(state: {
|
||||
return () => listeners.delete(fn)
|
||||
},
|
||||
},
|
||||
noteAgentPreset: (sessionId: string, agentPreset: string) => {
|
||||
const summary = state.byId[sessionId]
|
||||
if (summary === undefined || summary.agentPreset === agentPreset) return
|
||||
summary.agentPreset = agentPreset
|
||||
for (const fn of listeners) fn()
|
||||
},
|
||||
/** Push a list change the way the runtime's store does. */
|
||||
notify: () => { for (const fn of listeners) fn() },
|
||||
}
|
||||
@@ -347,6 +353,24 @@ describe('ui-agent-preset apply', () => {
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('folds a remote preset commit into the shared session row', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state = {
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
||||
}
|
||||
ctx.provide('sessions', sessionsDouble(state) as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
|
||||
ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal'])
|
||||
|
||||
expect(state.byId.s1.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('offers a just-authored preset on the new-session chip', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
@@ -6,7 +6,7 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
|
||||
|
||||
`src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`.
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有,业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claim(space / 带参 Enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
// registers nothing globally, so the registry-wide signal above never
|
||||
// fires for it: repull that key alone, soft, so the old snapshot serves
|
||||
// the menu until the new one lands.
|
||||
ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) })
|
||||
ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) })
|
||||
ctx.on('connection/reset', () => { this.directory.resetConnected() })
|
||||
}
|
||||
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('directory invalidation events', () => {
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('session/preset-changed repulls the recomposed session and leaves the others served', async () => {
|
||||
it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
|
||||
const rounds = new Map<SessionId, number>()
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: (payload) => {
|
||||
@@ -638,7 +638,7 @@ describe('directory invalidation events', () => {
|
||||
await warm(proj('s2'))
|
||||
// A preset switch changes which commands one session's agent resolves;
|
||||
// every other session keeps the catalog its own composition serves.
|
||||
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
|
||||
ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
|
||||
@@ -10,6 +10,8 @@ When the Host reports that no adapter serves the session's route (`session.model
|
||||
|
||||
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path.
|
||||
|
||||
Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. Provider topology, provider catalogs, and the default selection therefore converge without the Host or client runtime deriving a separate model-change alias.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state fields, and the seat's injected face type.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -10,6 +10,8 @@ Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方
|
||||
|
||||
目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行路径之外激活持久化 child 历史。
|
||||
|
||||
每一份常驻目录都会直接在转发的 owner 事件 `llm/adapters-updated` 与 `settings/document-updated` 上重拉。因此提供方拓扑、提供方目录与默认选择都能收敛,Host 与 client runtime 无需再派生一个单独的模型变更别名。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -45,6 +46,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -59,9 +61,11 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* history outside the direct-parent continuation path.
|
||||
*/
|
||||
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the forwarded Host-event face and ctx.remote merge.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
|
||||
@@ -96,7 +98,7 @@ function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | u
|
||||
const NS = 'model'
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots', 'remote']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount ModelService, register the `model` dictionaries,
|
||||
|
||||
@@ -32,7 +32,7 @@ interface LiveState {
|
||||
|
||||
/** The `ctx.models` session model-selection service. */
|
||||
export class ModelService extends Service {
|
||||
static inject = ['connection', 'sessions']
|
||||
static inject = ['connection', 'sessions', 'remote']
|
||||
|
||||
private readonly live: LiveState = { directories: new Map() }
|
||||
|
||||
@@ -49,14 +49,15 @@ export class ModelService extends Service {
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const directory of this.live.directories.values()) directory.resetConnected()
|
||||
})
|
||||
// Provider topology changed on the host (a settings-born route appeared
|
||||
// or dropped): refresh every open directory in the background so pickers
|
||||
// show the new catalog without a reopen. Failures stay on each store.
|
||||
ctx.on('models/changed', () => {
|
||||
// Either source can change the directory: registry topology commits and
|
||||
// settings documents that carry provider catalogs or default selection.
|
||||
const refresh = (): void => {
|
||||
for (const directory of this.live.directories.values()) {
|
||||
directory.load().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx.remote.$on('llm/adapters-updated', refresh)
|
||||
ctx.remote.$on('settings/document-updated', refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
@@ -112,6 +113,7 @@ async function bench() {
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
new TestRemote(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await ctx.plugin(function probe() {}).await()
|
||||
@@ -245,14 +247,14 @@ describe('ui-model dual entry', () => {
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
|
||||
b.setRoutable(false)
|
||||
b.ctx.emit('models/changed')
|
||||
b.ctx.remote.$dispatch('llm/adapters-updated', [])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
|
||||
|
||||
// Recovering clears it without a reload of the surface.
|
||||
b.setRoutable(true)
|
||||
b.ctx.emit('models/changed')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', refresh),
|
||||
ctx.remote.$on('credentials/updated', refresh),
|
||||
ctx.on('models/changed', refresh),
|
||||
ctx.remote.$on('llm/adapters-updated', refresh),
|
||||
ctx.on('connection/reset', refresh),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('pushed invalidations', () => {
|
||||
// The fake wire face has no methods: a fetch attempt would throw.
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
|
||||
b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY'])
|
||||
b.ctx.emit('models/changed')
|
||||
b.ctx.remote.$dispatch('llm/adapters-updated', [])
|
||||
b.ctx.emit('connection/reset')
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`.
|
||||
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`.
|
||||
|
||||
A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
|
||||
skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
|
||||
|
||||
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -46,6 +47,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -58,6 +60,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* accent row derived only from each logged call/result slice.
|
||||
*/
|
||||
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the forwarded Host-event face and ctx.remote merge.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -53,7 +55,7 @@ interface CatalogFetch {
|
||||
}
|
||||
|
||||
/** Required services: reference source faces plus the tool-row and locale registries. */
|
||||
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale']
|
||||
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale', 'remote']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '/' source, dictionaries, and keyed tool row.
|
||||
@@ -178,7 +180,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
// A preset decides which skill providers an agent reads, so a switched
|
||||
// session's cached catalog belongs to the composition it no longer runs.
|
||||
ctx.on('session/preset-changed', invalidate)
|
||||
ctx.remote.$on('agent-preset/selected', invalidate)
|
||||
ctx.on('connection/reset', clearAll)
|
||||
ctx.effect(() => {
|
||||
const unregister = slash.registerSource(source)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx'
|
||||
@@ -73,6 +74,7 @@ async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) {
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
new TestRemote(ctx)
|
||||
providePresentation(ctx)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { ctx, source: captured! }
|
||||
@@ -105,7 +107,7 @@ const req = (query: string, signal?: AbortSignal) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale'])
|
||||
expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale', 'remote'])
|
||||
})
|
||||
|
||||
it('registers the dedicated skill row and its locale dictionaries', async () => {
|
||||
@@ -113,6 +115,7 @@ describe('apply', () => {
|
||||
ctx.provide('slash', { registerSource: () => () => {} })
|
||||
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
|
||||
ctx.provide('sessions', { subagentAddress: () => undefined })
|
||||
new TestRemote(ctx)
|
||||
const presentation = providePresentation(ctx)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = presentation.slots.entries('tool.call.toolview')[0]
|
||||
@@ -263,7 +266,7 @@ describe('catalog cache', () => {
|
||||
expect(payloads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('session/preset-changed clears only the recomposed session', async () => {
|
||||
it('agent-preset/selected clears only the recomposed session', async () => {
|
||||
const { list, payloads } = countingList()
|
||||
const { ctx, source } = await bench(list)
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
@@ -271,7 +274,7 @@ describe('catalog cache', () => {
|
||||
expect(payloads).toHaveLength(2)
|
||||
// The catalog a preset supplies is the preset's; the other session's
|
||||
// composition did not change, so its cached catalog still holds.
|
||||
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
|
||||
ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
await source.candidates(proj('s2'), req(''))
|
||||
expect(payloads).toHaveLength(3)
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
|
||||
|
||||
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `host/models-changed` stays a derived frame of this package's own: it is fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its forwarded settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`host/models-changed` 仍是本包自有的派生帧:它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的转发 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { dirname } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -3467,10 +3467,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}),
|
||||
// Allowlisted host events ride one verbatim wrapper frame each. The
|
||||
// allowlist is api-remotes', and `ctx.remote.$on` is the consumer
|
||||
// face; nothing here projects, redacts, or renames. Registered ahead
|
||||
// of the derived frames below so a forwarded event still precedes the
|
||||
// invalidation derived from it (`settings/document-updated` before
|
||||
// its `host/models-changed`), which is the order a client sees.
|
||||
// face; nothing here projects, redacts, or renames.
|
||||
...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on(
|
||||
name,
|
||||
// The allowlist's shape assertion proves each name is a real,
|
||||
@@ -3485,37 +3482,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}))
|
||||
}),
|
||||
)),
|
||||
// The recompose itself registers nothing (it re-parents the agent's
|
||||
// scope onto a standing mount that may already exist), so the
|
||||
// logged selection is the only commit point a client can follow.
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type !== 'agent-preset/selected') return
|
||||
queue.push(frame({
|
||||
type: 'host/session-preset-changed',
|
||||
sessionId: session.id,
|
||||
agentPreset: event.data.agentPreset,
|
||||
}))
|
||||
}),
|
||||
ctx.on('settings/document-updated', (ns) => {
|
||||
// The RAW-section event, not the resolved one: a field going from
|
||||
// inherited to overridden leaves the resolved value equal, and a
|
||||
// configuration client still has to re-read (its held revision is
|
||||
// stale, and the field's meaning changed).
|
||||
const name = String(ns)
|
||||
// A provider's own settings carry its model catalog and endpoint,
|
||||
// so a change there invalidates the model list even when the route
|
||||
// set is untouched — `llm/adapters-updated` alone misses it. The
|
||||
// Agent default section is the other such source: it names the
|
||||
// selection every session with no logged one resolves to, so an
|
||||
// externally edited default (another tab, a hand-edited
|
||||
// settings.yaml) has to reach an open selector too.
|
||||
if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}
|
||||
}),
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
@@ -88,7 +88,5 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
// structural contract belongs to the owner package's cordis `Events`
|
||||
// declaration — the host validated JSON-safety before forwarding.
|
||||
z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }),
|
||||
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -150,23 +150,4 @@ export type HostFrame =
|
||||
* per-event frame variant.
|
||||
*/
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
/**
|
||||
* One blank session was recomposed onto another agent preset (the logged
|
||||
* `agent-preset/selected` commit point, read off the session stream). The
|
||||
* registry-wide `commands/change` forwarded above cannot stand in for it:
|
||||
* recomposing re-parents that agent's scope without registering anything,
|
||||
* so a preset already mounted for another session produces no registry
|
||||
* change at all. Clients refetch the catalogs this session's composition
|
||||
* decides (`command.list`, `skill.list`) for this sessionId alone, and fold
|
||||
* the preset id into their session row — the RPC echo reaches only the
|
||||
* client that issued the switch, so the row is where every other one learns
|
||||
* it.
|
||||
*/
|
||||
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
|
||||
/**
|
||||
* The provider topology changed (`llm/adapters-updated` passthrough):
|
||||
* routes registered or dropped, or the configurable directory moved. Pure
|
||||
* invalidation: clients refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
*/
|
||||
| { type: 'host/models-changed' }
|
||||
| { type: 'stream/error'; error: RpcError }
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog
|
||||
* (the same groups as `session.models`, without a per-session selection).
|
||||
* Both invalidate on the `host/models-changed` frame.
|
||||
* Clients invalidate from the forwarded `llm/adapters-updated` and
|
||||
* `settings/document-updated` owner events.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('agentPreset.select', () => {
|
||||
.toBe('minimal')
|
||||
})
|
||||
|
||||
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
|
||||
it('forwards the committed switch so clients can drop that session\'s catalogs', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
|
||||
// The host-stream opener reads the committed-workspace baseline; this
|
||||
@@ -385,7 +385,8 @@ describe('agentPreset.select', () => {
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
const consume = (async () => {
|
||||
for await (const frame of stream) {
|
||||
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
|
||||
if (frame.payload.type === 'host/remote-event'
|
||||
&& frame.payload.event === 'agent-preset/selected') frames.push(frame.payload)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -397,10 +398,10 @@ describe('agentPreset.select', () => {
|
||||
abort.abort()
|
||||
await consume
|
||||
|
||||
// Recomposing registers nothing, so this frame — not the registry-wide
|
||||
// commands one — is what tells a client its cached catalogs are stale.
|
||||
// Recomposing registers nothing, so the owner event — not the
|
||||
// registry-wide commands one — tells clients their cached catalogs are stale.
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
|
||||
{ type: 'host/remote-event', event: 'agent-preset/selected', args: ['sel-frame', 'minimal'] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ describe('settings domain', () => {
|
||||
.toBe('settings-not-exposed')
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
|
||||
it('forwards a provider settings change for model-catalog consumers', async () => {
|
||||
// Editing `models` changes no route, so llm/adapters-updated never fires
|
||||
// and an open model picker would keep serving the stale catalog. Storing
|
||||
// an override equal to the resolved value emits nothing on
|
||||
@@ -440,13 +440,10 @@ describe('settings domain', () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 1, async () => {
|
||||
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
forwardedSettings('llm-deepseek'),
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
expect(frames).toEqual([forwardedSettings('llm-deepseek')])
|
||||
// The resolved value never moved: base already said https://base.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value)
|
||||
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
|
||||
@@ -460,13 +457,13 @@ describe('settings domain', () => {
|
||||
base: { defaultPreset: 'read-only' },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 1, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 1, async () => {
|
||||
await permission.update({ defaultPreset: 'workspace-write' })
|
||||
})
|
||||
expect(frames).toEqual([forwardedSettings('permission')])
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when the Agent default selection changes', async () => {
|
||||
it('forwards an Agent-default settings change for model-catalog consumers', async () => {
|
||||
const ctx = await harness()
|
||||
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
|
||||
provider: z.string().required(),
|
||||
@@ -476,13 +473,10 @@ describe('settings domain', () => {
|
||||
// The shared section names the selection every blank session resolves to,
|
||||
// so an externally edited default — another tab, a
|
||||
// hand-edited settings.yaml — has to reach an open selector as well.
|
||||
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 1, async () => {
|
||||
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
forwardedSettings('agent-default-model'),
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
expect(frames).toEqual([forwardedSettings('agent-default-model')])
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
@@ -641,15 +635,18 @@ describe('llm domain', () => {
|
||||
expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }])
|
||||
})
|
||||
|
||||
it('broadcasts host/models-changed at every topology commit point', async () => {
|
||||
it('forwards llm/adapters-updated at every topology commit point', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/models-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 2, async () => {
|
||||
const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', []))
|
||||
dispose()
|
||||
return Promise.resolve()
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/remote-event', event: 'llm/adapters-updated', args: [] },
|
||||
{ type: 'host/remote-event', event: 'llm/adapters-updated', args: [] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -507,7 +507,8 @@ describe('events frame schemas', () => {
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
{ type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] },
|
||||
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
|
||||
{ type: 'host/remote-event', event: 'agent-preset/selected', args: ['s', 'minimal'] },
|
||||
{ type: 'host/remote-event', event: 'llm/adapters-updated', args: [] },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
|
||||
@@ -63,16 +63,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
|
||||
/**
|
||||
* The provider topology changed: an adapter registered or unregistered
|
||||
* routes, or the configurable-provider directory gained or lost entries.
|
||||
* This is a payload-free registry notification fired at each commit point
|
||||
* (including registration disposal); consumers re-read `listProviders()`,
|
||||
* `listModels()`, or `listConfigurableProviders()` for the new state.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'llm/adapters-updated'(): void
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
|
||||
import type { Message } from './message.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* The provider topology changed: an adapter registered or unregistered
|
||||
* routes, or the configurable-provider directory gained or lost entries.
|
||||
* This payload-free registry notification fires at each commit point
|
||||
* (including registration disposal); consumers re-read `listProviders()`,
|
||||
* `listModels()`, or `listConfigurableProviders()` for the new state.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'llm/adapters-updated'(): void
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
AssistantMessage,
|
||||
AssistantProvenance,
|
||||
|
||||
@@ -41,7 +41,7 @@ The child records the joined id on its own durable header ([`dsh-subagent`](../.
|
||||
|
||||
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
|
||||
|
||||
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
|
||||
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. The service re-emits that committed fact as the non-scoped cordis event `agent-preset/selected(sessionId, agentPreset)` declared by the client-safe `./types` export, allowing remote consumers to invalidate session-derived state without importing Host runtime types. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
|
||||
|
||||
### Switching a blank agent
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,
|
||||
|
||||
创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
|
||||
|
||||
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
|
||||
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。服务会把这项已提交事实重新发为不带 scope 的 cordis 事件 `agent-preset/selected(sessionId, agentPreset)`,其声明位于 client-safe 的 `./types` 出口,使远端消费方无需导入 Host 运行时类型即可让会话派生状态失效。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
|
||||
|
||||
### 切换空白 agent
|
||||
|
||||
|
||||
@@ -22,12 +22,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -17,7 +17,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
|
||||
|
||||
/** A preset id that cannot be used as a directory name under a root. */
|
||||
export class InvalidPresetIdError extends Error {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { load } from 'js-yaml'
|
||||
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { readPresetMetadata } from './metadata.ts'
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts'
|
||||
import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
|
||||
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
|
||||
@@ -32,7 +32,8 @@ import { discoverPresets } from './discovery.ts'
|
||||
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts'
|
||||
import type {} from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
@@ -61,8 +62,8 @@ export {
|
||||
PresetNotWritableError, readComposition, writableRoot,
|
||||
} from './authoring.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './preset.ts'
|
||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -154,6 +155,13 @@ export class AgentPresets extends Service {
|
||||
+ '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)',
|
||||
)
|
||||
})
|
||||
|
||||
// The durable record is the commit point. Its public notification carries
|
||||
// only the stable identity needed by clients, never the live Session.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'agent-preset/selected') return
|
||||
ctx.emit('agent-preset/selected', session.id, event.data.agentPreset)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { Include } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { PresetMountError, type AgentPreset } from './types.ts'
|
||||
import { PresetMountError, type AgentPreset } from './preset.ts'
|
||||
|
||||
/** What one mounted subtree publishes about itself for the audit to read. */
|
||||
interface MountedTree {
|
||||
|
||||
88
packages/preset/agent-presets/src/preset.ts
Normal file
88
packages/preset/agent-presets/src/preset.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/** Agent-preset vocabulary shared by discovery, mounting, and consumers. */
|
||||
|
||||
/**
|
||||
* Where a preset's composition came from. A `system` preset ships with the
|
||||
* deployment; a `user` preset was authored locally, by a person or by an
|
||||
* agent, and therefore carries the same trust as shell access.
|
||||
*/
|
||||
export type PresetTrust = 'system' | 'user'
|
||||
|
||||
/**
|
||||
* Ids a preset directory may use.
|
||||
*
|
||||
* The id becomes a path segment, so this is a containment boundary rather than
|
||||
* a style rule: `..`, a separator, or an absolute-looking name would place the
|
||||
* composition outside the root the deployment authorised. Discovery shares it:
|
||||
* a directory whose name no copy could ever claim is not a preset slot.
|
||||
*/
|
||||
export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** One preset directory that carries a mountable agent composition. */
|
||||
export interface AgentPreset {
|
||||
/** Stable identifier; the preset directory's name. */
|
||||
readonly id: string
|
||||
/** Trust recorded from the root this preset was discovered under. */
|
||||
readonly trust: PresetTrust
|
||||
/** Absolute path of the preset's agent composition file. */
|
||||
readonly path: string
|
||||
/** Display name from the preset's own metadata; absent falls back to {@link id}. */
|
||||
readonly name?: string
|
||||
/** One sentence on what this preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
/** Declared position within its group; absent sorts after those that declare one. */
|
||||
readonly order?: number
|
||||
/**
|
||||
* Why this preset cannot compose a session, absent when it can. A broken
|
||||
* preset stays on the roster — hiding it would leave its directory blocking
|
||||
* the id with nothing to see or delete — but every mounting path refuses it
|
||||
* up front with this reason instead of failing deep inside the loader.
|
||||
*/
|
||||
readonly broken?: string
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
export interface PresetRoot {
|
||||
/** Directory holding one subdirectory per preset; a leading `~` expands. */
|
||||
path: string
|
||||
/** Trust recorded on every preset discovered under this root. */
|
||||
trust: PresetTrust
|
||||
}
|
||||
|
||||
/** Plugin config: which preset is the default, and where presets live. */
|
||||
export interface Config {
|
||||
/** Preset id mounted when a caller names none. Missing at mount time fails loud. */
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
}
|
||||
|
||||
/**
|
||||
* No configured root supplies the requested preset.
|
||||
*
|
||||
* Separate from a mount failure because the two mean different things to a
|
||||
* caller: an unknown id is a bad request, while an unusable composition is a
|
||||
* broken preset the deployment must fix.
|
||||
*/
|
||||
export class UnknownPresetError extends Error {
|
||||
constructor(
|
||||
/** The id that was requested. */
|
||||
readonly presetId: string,
|
||||
/** Ids the roster does supply, for the caller to offer instead. */
|
||||
readonly available: readonly string[],
|
||||
) {
|
||||
super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A preset exists but its composition cannot be installed. */
|
||||
export class PresetMountError extends Error {
|
||||
constructor(
|
||||
/** The preset whose composition failed. */
|
||||
readonly presetId: string,
|
||||
/** Why it failed, without this package's own message prefix. */
|
||||
readonly reason: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,17 @@
|
||||
/** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */
|
||||
/** Client-safe event declarations owned by the agent-preset domain. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/**
|
||||
* Where a preset's composition came from. A `system` preset ships with the
|
||||
* deployment; a `user` preset was authored locally, by a person or by an
|
||||
* agent, and therefore carries the same trust as shell access.
|
||||
*/
|
||||
export type PresetTrust = 'system' | 'user'
|
||||
|
||||
/**
|
||||
* Ids a preset directory may use.
|
||||
*
|
||||
* The id becomes a path segment, so this is a containment boundary rather than
|
||||
* a style rule: `..`, a separator, or an absolute-looking name would place the
|
||||
* composition outside the root the deployment authorised. Discovery shares it:
|
||||
* a directory whose name no copy could ever claim is not a preset slot.
|
||||
*/
|
||||
export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** One preset directory that carries a mountable agent composition. */
|
||||
export interface AgentPreset {
|
||||
/** Stable identifier; the preset directory's name. */
|
||||
readonly id: string
|
||||
/** Trust recorded from the root this preset was discovered under. */
|
||||
readonly trust: PresetTrust
|
||||
/** Absolute path of the preset's agent composition file. */
|
||||
readonly path: string
|
||||
/** Display name from the preset's own metadata; absent falls back to {@link id}. */
|
||||
readonly name?: string
|
||||
/** One sentence on what this preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
/** Declared position within its group; absent sorts after those that declare one. */
|
||||
readonly order?: number
|
||||
/**
|
||||
* Why this preset cannot compose a session, absent when it can. A broken
|
||||
* preset stays on the roster — hiding it would leave its directory blocking
|
||||
* the id with nothing to see or delete — but every mounting path refuses it
|
||||
* up front with this reason instead of failing deep inside the loader.
|
||||
*/
|
||||
readonly broken?: string
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
export interface PresetRoot {
|
||||
/** Directory holding one subdirectory per preset; a leading `~` expands. */
|
||||
path: string
|
||||
/** Trust recorded on every preset discovered under this root. */
|
||||
trust: PresetTrust
|
||||
}
|
||||
|
||||
/** Plugin config: which preset is the default, and where presets live. */
|
||||
export interface Config {
|
||||
/** Preset id mounted when a caller names none. Missing at mount time fails loud. */
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
}
|
||||
|
||||
/**
|
||||
* No configured root supplies the requested preset.
|
||||
*
|
||||
* Separate from a mount failure because the two mean different things to a
|
||||
* caller: an unknown id is a bad request, while an unusable composition is a
|
||||
* broken preset the deployment must fix.
|
||||
*/
|
||||
export class UnknownPresetError extends Error {
|
||||
constructor(
|
||||
/** The id that was requested. */
|
||||
readonly presetId: string,
|
||||
/** Ids the roster does supply, for the caller to offer instead. */
|
||||
readonly available: readonly string[],
|
||||
) {
|
||||
super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`)
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* One session committed a different agent preset to its durable log.
|
||||
* Consumers invalidate only state derived from that session's composition.
|
||||
* @mode emit
|
||||
* @param sessionId - the session whose composition changed.
|
||||
* @param agentPreset - the preset recorded by the committed selection.
|
||||
*/
|
||||
'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void
|
||||
}
|
||||
}
|
||||
|
||||
/** A preset exists but its composition cannot be installed. */
|
||||
export class PresetMountError extends Error {
|
||||
constructor(
|
||||
/** The preset whose composition failed. */
|
||||
readonly presetId: string,
|
||||
/** Why it failed, without this package's own message prefix. */
|
||||
readonly reason: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options)
|
||||
}
|
||||
}
|
||||
export {}
|
||||
|
||||
Reference in New Issue
Block a user