Merge pull request #1543 from deepseek-harness/stack/agent-profiles-8-authoring
feat(web): author agent presets from a settings page
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: eff1d9522e3ca6e8a7efaa20463d73036101f8f5
|
||||
README.zh.md: cc2d37d3999e2e59095a8000feb3f963d0b4e4a1
|
||||
README.md: c18a46b7131f7782be68f3c96fa99b89615de471
|
||||
README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851
|
||||
|
||||
@@ -34,6 +34,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
|
||||
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
|
||||
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 |
|
||||
|
||||
@@ -22,8 +22,20 @@ export type ApiRemoteAgentResult =
|
||||
export interface ApiRemoteAgentOptions {
|
||||
/** Read the per-Agent defaults when a cold identity must resume. */
|
||||
readonly agentOptions?: () => AgentOptions
|
||||
/** Host-specific Agent-scope composition completed before publication. */
|
||||
readonly setup?: AgentSetup
|
||||
/**
|
||||
* Build the Host-specific Agent-scope composition completed before
|
||||
* publication. Keyed by the resumed session itself because what a Host
|
||||
* installs may depend on what that session recorded: an agent preset fixes
|
||||
* the tools its history was produced under, so rebuilding it under another
|
||||
* composition would replay tool calls the agent can no longer make. The
|
||||
* events come along because a session's own record of such a choice may be
|
||||
* an event rather than a header field.
|
||||
* @param session - the resumed session's persisted header and event log.
|
||||
* @returns the Agent-scope setup to run before publication.
|
||||
*/
|
||||
readonly setup?: (
|
||||
session: { meta: SessionHeader; events: readonly SessionEvent[] },
|
||||
) => AgentSetup | Promise<AgentSetup>
|
||||
}
|
||||
|
||||
/** Cold identity absent from the durable session store. */
|
||||
@@ -136,6 +148,11 @@ export function createApiRemoteAgentResolver(
|
||||
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
// Built from the inspected session before the published re-checks
|
||||
// below, so those stay adjacent to `resume` and a Host setup that
|
||||
// awaits (composing a preset, say) does not widen the collision
|
||||
// window.
|
||||
const setup = options.setup === undefined ? undefined : await options.setup(inspected)
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
const publishedAgent = ctx.agents.get(sessionId)
|
||||
if (publishedSession !== undefined
|
||||
@@ -145,7 +162,7 @@ export function createApiRemoteAgentResolver(
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
|
||||
...options.setup === undefined ? {} : { setup: options.setup },
|
||||
...setup === undefined ? {} : { setup },
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: 1bbd376121ae79bf37376b51f5ac3eb405af6dfd
|
||||
README.zh.md: 15263d8de9b69fc976ce328a6056e1b35e9beda5
|
||||
README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0
|
||||
README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c
|
||||
|
||||
@@ -15,7 +15,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR |
|
||||
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
@@ -27,6 +27,8 @@ Loader settlement rejects import and lifecycle failures with the failing entry a
|
||||
|
||||
The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown.
|
||||
|
||||
`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
@@ -27,6 +27,8 @@ Loader 结算会在导入或生命周期失败时返回拒绝结果,并携带
|
||||
|
||||
Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先 dispose 部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前 dispose 整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间,处理函数保持注册并处于锁定状态:被报告的始终是第一个 rejection,后续拒绝(包括拆卸自身产生的拒绝)会被忽略,而不会变成未捕获错误、在拆卸中途杀死进程。
|
||||
|
||||
`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-group": "^1.0.0",
|
||||
"@cordisjs/plugin-hmr": "^1.0.15",
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
@@ -43,6 +44,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-group": "workspace:^",
|
||||
"@cordisjs/plugin-hmr": "workspace:^",
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as yaml from 'js-yaml'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import Group from '@cordisjs/plugin-group'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import type {} from '@cordisjs/plugin-hmr'
|
||||
@@ -485,6 +486,12 @@ export async function mountRootInclude(
|
||||
patches: readonly PatchOptions[] = [],
|
||||
): Promise<Entry | undefined> {
|
||||
ctx.loader.builtins.include = Include
|
||||
// `cordis:group` alongside it: a group row is how a composition gives one
|
||||
// `isolate` realm to a provider and its consumers together, and an agent
|
||||
// preset living outside this workspace cannot resolve `@cordisjs/plugin-group`
|
||||
// by name. Both builtins load through the ambient module pipeline, so neither
|
||||
// depends on the included tree's own specifier resolution.
|
||||
ctx.loader.builtins.group = Group
|
||||
// Pinned id: the bootstrap include is app glue, not a config row, and its
|
||||
// id appears in Loader failure chains — a random id would make startup
|
||||
// diagnostics unstable across runs (and snapshot fixtures).
|
||||
|
||||
@@ -8,9 +8,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { Group } from '@cordisjs/plugin-loader'
|
||||
import { boot } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -219,8 +218,10 @@ describe('loader tree replacement', () => {
|
||||
})
|
||||
|
||||
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
|
||||
// No manual builtin registration: `boot()` supplies `cordis:group` beside
|
||||
// `cordis:include`, which is what lets a composition give one `isolate`
|
||||
// realm to a provider and its consumers together.
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const config = (disabled: boolean) => [
|
||||
'- id: parent',
|
||||
@@ -253,7 +254,6 @@ describe('loader tree replacement', () => {
|
||||
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
|
||||
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
|
||||
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
|
||||
@@ -386,3 +386,46 @@ describe('include patches layered over one base', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('shipped builtins', () => {
|
||||
it('lets a booted composition share one isolate realm across a group of rows', async () => {
|
||||
// The reason `boot()` registers `cordis:group`: a composition — notably an
|
||||
// agent preset living outside this workspace, which cannot resolve
|
||||
// `@cordisjs/plugin-group` by name — gives a provider and its consumer one
|
||||
// named realm so the service stays out of the root realm while remaining
|
||||
// visible to the rows that need it.
|
||||
const { ctx } = await bootTree([
|
||||
'- id: realm',
|
||||
' name: cordis:group',
|
||||
' isolate:',
|
||||
' demoRealmSvc: true',
|
||||
' config:',
|
||||
' - id: provider',
|
||||
' name: ./provider.mjs',
|
||||
' - id: consumer',
|
||||
' name: ./consumer.mjs',
|
||||
'',
|
||||
].join('\n'), {
|
||||
'provider.mjs': 'export const name = "provider"\n'
|
||||
+ 'export function apply(ctx) { ctx.effect(() => ctx.reflect.provide("demoRealmSvc", { tag: "realm" })) }\n',
|
||||
'consumer.mjs': 'export const name = "consumer"\n'
|
||||
+ 'export const inject = ["demoRealmSvc"]\n'
|
||||
+ 'export function apply(ctx) { globalThis.__REALM_SEEN__ = ctx.get("demoRealmSvc").tag }\n',
|
||||
})
|
||||
try {
|
||||
expect((globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__).toBe('realm')
|
||||
// `provide` mints the root symbol unconditionally (cordis `reflect.ts`),
|
||||
// so the name IS in the root realm — pinned here because it is the half
|
||||
// that looks like the claim and is not. The claim is the other half: no
|
||||
// implementation is stored under that symbol, so the root realm cannot
|
||||
// resolve the service and a second composition mounting the same rows
|
||||
// cannot collide with this one.
|
||||
const rootKey = ctx.root[Context.isolate].demoRealmSvc
|
||||
expect(rootKey).toBeDefined()
|
||||
expect(ctx.reflect.store[rootKey!]).toBeUndefined()
|
||||
} finally {
|
||||
delete (globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/group"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/hmr"
|
||||
},
|
||||
|
||||
@@ -183,6 +183,11 @@
|
||||
- id: ui-permission
|
||||
name: '@deepseek-ai/dsh-client-ui-permission'
|
||||
|
||||
# The agent-preset row in General settings: the default preset for
|
||||
# sessions created later. Absent a roster it renders nothing.
|
||||
- id: ui-agent-preset
|
||||
name: '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
@@ -192,3 +197,136 @@
|
||||
|
||||
- id: ui-trajectory
|
||||
name: '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
# ── the agent plane moves behind agent presets ─────────────────────────────
|
||||
#
|
||||
# Every row below composes what ONE agent contributes to the host registries:
|
||||
# its tools, its prompt sections, its delegation backends. The base keeps them
|
||||
# for the TUI, which is single-session and composes its agent process-wide; the
|
||||
# Web surface disables them here and lets each session mount a preset instead.
|
||||
#
|
||||
# Disabling rather than deleting is deliberate: the base is shared, and a row
|
||||
# absent from a surface overlay would silently reappear the day someone reorders
|
||||
# the composition.
|
||||
|
||||
# `bash-env` STAYS in the host plane: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# would never reach the model's shell at all.
|
||||
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The `skill` REGISTRY stays in the host plane. It is host+per-scope layered
|
||||
# (the tools-registry shape): deployment-level providers — repository plugins,
|
||||
# a host skill-local row — register into its global layer, while a preset's
|
||||
# `skill-local` registers into that preset's layer, and each agent reads the
|
||||
# merged catalog its scope chain selects. Only the per-agent rows move behind
|
||||
# presets: the base host `skill-local` row is disabled here (presets own local
|
||||
# discovery), and `tool-skill` is what a preset mounts to give its agent the
|
||||
# catalog and loader at all.
|
||||
|
||||
- id: skill-local
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
# The goal SERVICE, its session driver, and the `/goal` command STAY on the
|
||||
# host plane; only the model-facing tool moves. The Gateway serves the goal
|
||||
# domain as Remote endpoints, and a Remote method picks its receiver Service
|
||||
# from a generated descriptor — it resolves `goals` on the host, so a
|
||||
# per-session realm would answer `service-unavailable` for every browser call.
|
||||
# That is the `bash-env` criterion read from the other side: injection is not
|
||||
# the only host relationship a Service can have. The registry is keyed by
|
||||
# session, so one host instance serves every session exactly as before presets.
|
||||
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: token-meter
|
||||
disabled: true
|
||||
|
||||
- id: compact-basic
|
||||
disabled: true
|
||||
|
||||
- id: command-compact
|
||||
disabled: true
|
||||
|
||||
- id: tool-result-prune
|
||||
disabled: true
|
||||
|
||||
# The subagent registry and its backends STAY in the host plane. `subagents` is
|
||||
# a process singleton with a cross-session query surface (`listChildren`,
|
||||
# `followup`) that the host api-proxy serves to the browser, and a provider
|
||||
# registers under a globally unique name, so a per-session copy would both
|
||||
# starve that host row and collide on the second session. What a preset
|
||||
# chooses is which delegation TOOLS its agent sees, below.
|
||||
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
# `tool-subagent-report` is host-plane for the same reason as the registry, not
|
||||
# because a preset may not want it: it registers a CONTINUABLE SETUP on that
|
||||
# singleton rather than a tool this agent calls, and the setup list is not
|
||||
# scope-aware — one copy per mounted preset means every child gets `report`
|
||||
# registered once per live session, which throws on the second.
|
||||
|
||||
- id: workflow-workerthread
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: workspace-context
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
# The preset roster. `config/agent-presets/` ships with the deployment and is
|
||||
# read-only (its entries carry `system` trust);
|
||||
# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
|
||||
# carries the same trust as shell access because a preset IS a composition.
|
||||
# `roots` is an assembly fact, not user config: the shipped preset directory
|
||||
# ships beside this file, so AppCLIEntry resolves it and patches it in — the
|
||||
# same treatment `distIndex` gets on the webserver row.
|
||||
- insert:
|
||||
- id: agent-presets
|
||||
name: '@deepseek-ai/dsh-agent-presets'
|
||||
config:
|
||||
default: standard
|
||||
|
||||
@@ -32,12 +32,15 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: 56b9363cc724515ecbd11127ea4c13aba84283df
|
||||
README.zh.md: a3fe1a978de7ab5935ec527d115278703cbebcd4
|
||||
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
|
||||
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
|
||||
|
||||
@@ -33,6 +33,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
|
||||
@@ -33,6 +33,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 07849f0728aee6076b15a8216ddcab08521994d6
|
||||
README.zh.md: a7996d0f7cc2948da82877c47f9acc805be2bba8
|
||||
README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
|
||||
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since 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 (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -1357,6 +1357,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
|
||||
['DEEPSEEK_API_KEY', true],
|
||||
])
|
||||
/**
|
||||
* Preset compositions the fixture serves. Held as state rather than
|
||||
* constants so the settings editor's save and delete are exercisable: the
|
||||
* roster a GUI journey sees after writing is the text it wrote.
|
||||
*/
|
||||
const fixturePresets = new Map<string, { trust: 'system' | 'user'; content: string }>([
|
||||
['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }],
|
||||
['minimal', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }],
|
||||
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
|
||||
])
|
||||
let fixtureDefaultPreset = 'standard'
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
@@ -2444,6 +2455,88 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
list: request => ok(request, {
|
||||
presets: [...fixturePresets].map(([id, preset]) => ({
|
||||
id,
|
||||
trust: preset.trust,
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
}),
|
||||
select: (request) => {
|
||||
fixtureDefaultPreset = request.payload.agentPreset
|
||||
return ok(request, { agentPreset: request.payload.agentPreset })
|
||||
},
|
||||
read: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const preset = fixturePresets.get(agentPreset)
|
||||
if (preset === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${agentPreset}"`,
|
||||
details: { agentPreset, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
return ok(request, {
|
||||
agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
})
|
||||
},
|
||||
copy: (request) => {
|
||||
const { from, agentPreset } = request.payload
|
||||
const source = fixturePresets.get(from)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${from}"`,
|
||||
details: { agentPreset: from, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
if (fixturePresets.has(agentPreset)) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent preset "${agentPreset}" already exists`,
|
||||
details: { agentPreset, reason: 'already exists' },
|
||||
})
|
||||
}
|
||||
fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
|
||||
return ok(request, { agentPreset })
|
||||
},
|
||||
// Native opens are deterministic no-op successes in this fixture, so the
|
||||
// open-directory affordance renders and the path-text fallback stays a
|
||||
// component-test concern.
|
||||
openDocument: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing === undefined || existing.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
return ok(request, { opened: true as const })
|
||||
},
|
||||
remove: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing?.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
fixturePresets.delete(agentPreset)
|
||||
return ok(request, {})
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
@@ -2764,6 +2857,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
case 'command.execute': return this.api.commands.execute(request, signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'agentPreset.list': return this.api.agentPresets.list(request)
|
||||
case 'agentPreset.select': return this.api.agentPresets.select(request)
|
||||
case 'agentPreset.read': return this.api.agentPresets.read(request)
|
||||
case 'agentPreset.copy': return this.api.agentPresets.copy(request)
|
||||
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
|
||||
case 'agentPreset.remove': return this.api.agentPresets.remove(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
case 'goal.pause': return this.api.goals.pause(request)
|
||||
|
||||
@@ -66,6 +66,24 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
* keys, or key state — and a LAN client's model picker legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
// A preset composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance; copy and remove rearrange what the deployment offers, and
|
||||
// openDocument drives the host desktop — all more than the roster beside
|
||||
// them. (Authoring is copy-only, so no method here accepts composition text
|
||||
// or a path; the pin is about who may manage the roster at all.)
|
||||
//
|
||||
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
|
||||
// preset looks like escalation — one of them mounts the toolset that edits the
|
||||
// live runtime — but `session.create` already takes an `agentPreset`, so
|
||||
// pinning only the switch would leave the same capability one method over.
|
||||
// The deeper reason is that the capability is not the preset's to grant: the
|
||||
// deployment's own default already carries `bash` and the filesystem tools, so
|
||||
// any caller that may start a session at all can already run commands as this
|
||||
// process. Pinning the switch would be a fence beside an open gate.
|
||||
'agentPreset.read',
|
||||
'agentPreset.copy',
|
||||
'agentPreset.openDocument',
|
||||
'agentPreset.remove',
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
|
||||
@@ -172,6 +172,22 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -159,6 +159,10 @@ describe('connection node half', () => {
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
// A composition names the plugins a session runs: reading one is
|
||||
// reconnaissance, and copy/remove/openDocument manage the roster and
|
||||
// drive the host desktop.
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -452,13 +456,19 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
// `agentPreset.list` joins the model catalog for the same reason: ids and
|
||||
// trust only, and a LAN client's preset picker needs it. `select` is
|
||||
// reachable too: `session.create` already takes an `agentPreset`, and the
|
||||
// deployment's own default already carries bash, so pinning the switch
|
||||
// would be a fence beside an open gate.
|
||||
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
|
||||
@@ -62,6 +62,15 @@ export interface ISessions {
|
||||
* @returns completion of the current or newly started refresh.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Record the composition one session now runs. The agent-preset seat calls
|
||||
* this after a successful blank-session switch, so the header label moves
|
||||
* with the composition instead of waiting for the next full list refresh.
|
||||
* @param sessionId - the switched session.
|
||||
* @param agentPreset - the preset id the host confirmed.
|
||||
*/
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface SessionListEntry {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Agent preset the session's agent was composed from (summary passthrough). */
|
||||
agentPreset?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** User interaction currently blocking this session, derived from live mux frames. */
|
||||
|
||||
@@ -536,6 +536,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
|
||||
} })
|
||||
} else {
|
||||
const publishedSessionId = workspaceAttachSessionId(result.error)
|
||||
@@ -601,6 +602,17 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'upsert', summary })
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a host-confirmed composition switch (see ISessions.noteAgentPreset).
|
||||
* @param sessionId - the switched session.
|
||||
* @param agentPreset - the preset id the host confirmed.
|
||||
*/
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
|
||||
} })
|
||||
}
|
||||
|
||||
/** Apply immediately and retain for replay when a list response is in flight. */
|
||||
private recordMutation(mutation: SessionListMutation): void {
|
||||
this.listMutations?.push(mutation)
|
||||
@@ -756,6 +768,7 @@ export class SessionManager {
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
|
||||
@@ -1040,9 +1053,15 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
...(existing.origin === undefined && mutation.summary.origin !== undefined
|
||||
? { origin: mutation.summary.origin } : {}),
|
||||
// Newest wins, not fill-only: a blank-session preset switch replaces
|
||||
// the creation-time value, and every producer of this field (the
|
||||
// create echo, the select echo, a list row) reports the CURRENT one.
|
||||
...(mutation.summary.agentPreset !== undefined
|
||||
? { agentPreset: mutation.summary.agentPreset } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
||||
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
|
||||
&& filled.origin === existing.origin && filled.blank === existing.blank
|
||||
&& filled.agentPreset === existing.agentPreset) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface SessionSummary {
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from; absent when the
|
||||
* deployment composes no presets. The session header labels what the
|
||||
* session actually runs rather than the deployment's current default.
|
||||
*/
|
||||
agentPreset?: string
|
||||
parentId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
@@ -392,6 +398,10 @@ export class SessionsService implements ISessions {
|
||||
return this.manager.refreshSubagents(parentSessionId)
|
||||
}
|
||||
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.manager.noteAgentPreset(sessionId, agentPreset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
@@ -662,6 +672,7 @@ export class SessionsService implements ISessions {
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||||
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined) {
|
||||
|
||||
@@ -208,6 +208,22 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -430,6 +430,14 @@ export class TestSessions implements ISessions {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Apply a confirmed preset switch into the fixture list, as production does. */
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
this.list.update((draft) => {
|
||||
const summary = draft.byId[sessionId]
|
||||
if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset }
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear the current selection (recorded; the production no-session flow). */
|
||||
clear(): void {
|
||||
this.calls.push({ method: 'clear', args: [] })
|
||||
|
||||
@@ -221,6 +221,13 @@ describe('sessions', () => {
|
||||
.toMatchObject({ displayTitle: 'renamed', running: true })
|
||||
runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
|
||||
await runtime.sessions.refreshSubagents('s2' as SessionId)
|
||||
// The confirmed-switch write-back lands on the row it names and ignores
|
||||
// one the fixture never added, exactly as production's list upsert does.
|
||||
runtime.sessions.noteAgentPreset('s1' as SessionId, 'minimal')
|
||||
runtime.sessions.noteAgentPreset('missing' as SessionId, 'minimal')
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
|
||||
.toMatchObject({ agentPreset: 'minimal' })
|
||||
runtime.sessions.open('s1' as SessionId)
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
||||
|
||||
6
packages/client/ui-agent-preset/README.i18n.yaml
Normal file
6
packages/client/ui-agent-preset/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
|
||||
README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c
|
||||
README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd
|
||||
67
packages/client/ui-agent-preset/README.md
Normal file
67
packages/client/ui-agent-preset/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# dsh-client-ui-agent-preset
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files.
|
||||
|
||||
## Why it is a new-session preference
|
||||
|
||||
A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with.
|
||||
|
||||
## The new-session chip
|
||||
|
||||
A second surface, beside the workspace picker on the new-session screen. It sits there rather than in the composer because that is where the choice is still open: a control that spends most of its life disabled belongs on the screen where it still works.
|
||||
|
||||
The chip opens on the deployment default and its pick is *staged* — the screen precedes the session it would apply to. The stage reaches a session when one becomes current and is still blank, which covers both the session the workspace connect created and the blank one it reused; riding along on `sessions.create` would miss the second. It is spent on first use, so the next new session opens on the default again, exactly like the workspace picker beside it.
|
||||
|
||||
A session that has started is refused rather than queued: the host answers `agent-preset-locked`, and the stage is dropped instead of waiting for a session that will never accept it.
|
||||
|
||||
## 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.
|
||||
|
||||
## What it reads and writes
|
||||
|
||||
Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation.
|
||||
|
||||
A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted.
|
||||
|
||||
The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it.
|
||||
|
||||
## The management section
|
||||
|
||||
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions.
|
||||
|
||||
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
|
||||
|
||||
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
|
||||
|
||||
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
|
||||
|
||||
The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure.
|
||||
|
||||
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
|
||||
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
|
||||
|
||||
`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it.
|
||||
|
||||
## When the surfaces are absent
|
||||
|
||||
A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source.
|
||||
- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself.
|
||||
- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit.
|
||||
67
packages/client/ui-agent-preset/README.zh.md
Normal file
67
packages/client/ui-agent-preset/README.zh.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# dsh-client-ui-agent-preset
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。
|
||||
|
||||
## 为什么它是"新建会话"的偏好设置
|
||||
|
||||
会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
|
||||
|
||||
## 新建会话 chip
|
||||
|
||||
第二个表层,位于新建会话界面上、工作区选择器旁边。它落在这里而非 composer,是因为这里才是选择仍然成立的地方:一个大部分时间处于禁用状态的控件,属于它仍然可用的那个界面。
|
||||
|
||||
chip 以部署默认值打开,其选择是**暂存**的——该界面先于它要应用到的会话存在。暂存值会在某个会话成为当前会话且仍为空白时抵达该会话;这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。暂存值一经使用即被清空,因此下一个新会话重新以默认值打开——与它旁边的工作区选择器完全一致。
|
||||
|
||||
已经开始的会话会被直接拒绝而非排队:宿主返回 `agent-preset-locked`,暂存值随之丢弃,而不是去等一个永远不会接受它的会话。
|
||||
|
||||
## 会话标题旁的标签
|
||||
|
||||
第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。
|
||||
|
||||
## 它读什么、写什么
|
||||
|
||||
选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。
|
||||
|
||||
本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。
|
||||
|
||||
本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。
|
||||
|
||||
## 管理分区
|
||||
|
||||
第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。
|
||||
|
||||
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
|
||||
|
||||
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
|
||||
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
|
||||
|
||||
对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。
|
||||
|
||||
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
|
||||
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
|
||||
|
||||
`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。
|
||||
|
||||
## 何时不显示这些表层
|
||||
|
||||
未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。
|
||||
- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。
|
||||
- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。
|
||||
74
packages/client/ui-agent-preset/package.json
Normal file
74
packages/client/ui-agent-preset/package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
|
||||
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Session-header agent-preset label: static chrome, never a control. */
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The session header's agent-preset label.
|
||||
*
|
||||
* Read-only by construction: a session's composition is fixed once its
|
||||
* conversation starts, and a header is only worth reading after that. Offering
|
||||
* a control here would promise a switch the host refuses; naming what the
|
||||
* session runs is the honest affordance, and the choice itself lives on the
|
||||
* new-session screen ({@link AgentPresetSeat}).
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import css from './AgentPresetLabel.module.css'
|
||||
|
||||
/** Registration-side business face for the header label. */
|
||||
export interface AgentPresetLabelInjected {
|
||||
hooks: {
|
||||
/** Roster snapshot bound by the renderer as useAgentPresets. */
|
||||
agentPresets: SnapshotStore<AgentPresetSettingsState>
|
||||
}
|
||||
/** Read the roster, so the label can show a name rather than an id. */
|
||||
load: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetLabelProps =
|
||||
PropsRuntime<'conversation.session.header.actions'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetLabelInjected>
|
||||
|
||||
/**
|
||||
* Render this session's agent-preset name beside its title.
|
||||
* @param props - composed slot props.
|
||||
* @returns the label, or null when the session records no preset.
|
||||
*/
|
||||
export function AgentPresetLabel({
|
||||
sessionId, useSessions, useAgentPresets, load, t,
|
||||
}: AgentPresetLabelProps) {
|
||||
const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
|
||||
const options = useAgentPresets(state => state.options)
|
||||
|
||||
useEffect(() => {
|
||||
// Deployments that compose no presets never label anything, so the roster
|
||||
// is only worth a request once a session reports one.
|
||||
if (preset !== undefined) void load()
|
||||
}, [preset, load])
|
||||
|
||||
if (preset === undefined) return null
|
||||
|
||||
const option = options.find(entry => entry.id === preset)
|
||||
return (
|
||||
<span className={css.label} title={option?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
{option?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Agent-preset row: title/description plus the preset selector pill. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Agent-preset preference row: the preset new sessions are composed from.
|
||||
* A running session keeps the composition it began with, so this row never
|
||||
* disturbs work in progress.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import { PresetMenu } from './PresetMenu.tsx'
|
||||
import css from './AgentPresetRow.module.css'
|
||||
|
||||
/** Registration-side business face for the host-backed preference. */
|
||||
export interface AgentPresetRowInjected {
|
||||
hooks: {
|
||||
/** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */
|
||||
agentPreset: SnapshotStore<AgentPresetSettingsState>
|
||||
}
|
||||
/** Load the roster when the row first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Persist one preset as the default for later sessions. */
|
||||
select: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetRowProps =
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetRowInjected>
|
||||
|
||||
/**
|
||||
* Render the new-session agent-preset selector.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) {
|
||||
const state = useAgentPreset(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.writable && state.status !== 'unavailable') return
|
||||
setOpen(false)
|
||||
}, [state.status, state.writable])
|
||||
|
||||
// A deployment that composes no presets has nothing to choose between, and
|
||||
// every session shares the host composition — the row simply does not exist.
|
||||
if (state.status === 'unavailable') return null
|
||||
const busy = state.status === 'loading' || state.status === 'saving'
|
||||
// The metadata name is what every other surface shows — the id is the
|
||||
// addressing, not the label. A preset that names itself nothing falls back
|
||||
// to its id, which is then all there is to say about it.
|
||||
const chosen = state.options.find(option => option.id === state.currentValue)
|
||||
const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue)
|
||||
const description: string = state.error ?? t('description')
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
</div>
|
||||
<PresetMenu
|
||||
options={state.options}
|
||||
selectedId={state.currentValue}
|
||||
label={label}
|
||||
userTrustLabel={t('userTrust')}
|
||||
buttonClassName={css.selector}
|
||||
chevronClassName={css.chevron}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={(id) => { void select(id) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Agent-preset row copy. */
|
||||
'settings.agentPreset': AgentPresetSettingsKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Agent-preset chip on the new-session screen, beside the workspace picker.
|
||||
Geometry mirrors HeroShell's .workspace so the two read as one row. */
|
||||
|
||||
.seat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: min(100%, 240px);
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seat:not(:disabled):hover,
|
||||
.seat[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.seat:disabled {
|
||||
cursor: default;
|
||||
color: var(--dsw-alias-label-quaternary);
|
||||
}
|
||||
|
||||
.seatIcon {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Menu rows carry the name over its description: the id alone never said what
|
||||
a preset does, which is why the metadata exists. */
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
white-space: normal;
|
||||
}
|
||||
100
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
Normal file
100
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The agent-preset chip on the new-session screen, beside the workspace
|
||||
* picker.
|
||||
*
|
||||
* It lives here rather than in the composer because the choice is only
|
||||
* available before a conversation starts: once a turn has run, the session's
|
||||
* history was produced under that preset's tools and the host refuses to swap
|
||||
* them. A control that spends most of its life disabled belongs on the screen
|
||||
* where it still works.
|
||||
*
|
||||
* The menu opens on the staged choice, which starts as the deployment default.
|
||||
* Picking stages; the choice reaches a session when one becomes current.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSeatState } from './seat-store.ts'
|
||||
import css from './AgentPresetSeat.module.css'
|
||||
|
||||
/** Registration-side business face for the hero chip. */
|
||||
export interface AgentPresetSeatInjected {
|
||||
hooks: {
|
||||
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
|
||||
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
|
||||
}
|
||||
/** Read the roster when the chip first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSeatProps =
|
||||
PropsRuntime<'conversation.hero.agentPreset'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSeatInjected>
|
||||
|
||||
/**
|
||||
* Render the new-session agent-preset chip.
|
||||
* @param props - composed slot props.
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (state.options.length === 0 || state.current === '') return null
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({
|
||||
id: option.id,
|
||||
// Name and description together: the id alone never said what a
|
||||
// preset does, which is the whole reason the metadata exists.
|
||||
label: (
|
||||
<span className={css.item}>
|
||||
<span className={css.itemName}>{option.name ?? option.id}</span>
|
||||
<span className={css.itemDesc}>{option.description ?? t('noDescription')}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id)
|
||||
}}
|
||||
align="start"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.seat}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={state.error ?? t('seatHint')}
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosen?.name ?? state.current}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Cards, not rows: a preset is a thing you pick, and the description is the
|
||||
part that tells them apart — a row would bury it beside the actions. */
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.groupHead {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
|
||||
/* Every row the same height, so a short description does not make its card
|
||||
shorter than the one beside it. */
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
transition: border-color .16s, background .16s;
|
||||
}
|
||||
|
||||
|
||||
.card:hover:not(.cardActive) {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* The default preset reads as selected, not merely badged. */
|
||||
.cardActive {
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border-color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* A broken preset reads as damaged before anything else: the card cannot be
|
||||
picked, so its border carries the warning the disabled body cannot. */
|
||||
.cardBroken {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.cardBroken:hover {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.brokenBadge {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* The discovery-reported reason, verbatim: it names the file and the fix. */
|
||||
.cardBrokenReason {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The card body is the control that picks the preset. */
|
||||
.cardMain {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 12px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.cardMain:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cardMain:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.inUse {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.inUse {
|
||||
margin-left: auto;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.cardId {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Icon-only actions: the label rides `title` so the row stays quiet until
|
||||
someone reaches for it. */
|
||||
.iconButton {
|
||||
position: relative;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
padding: 6px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.iconButton::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .12s;
|
||||
}
|
||||
|
||||
.iconButton:hover::after,
|
||||
.iconButton:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.iconDanger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Where the host has no desktop opener, the row answers with the directory
|
||||
itself — text to copy, not a control that would spawn into nothing. */
|
||||
.revealedPath {
|
||||
margin: 0;
|
||||
padding: 6px 16px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.revealedPath code {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
user-select: all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.revealedPathLabel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 5px 8px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.secondaryButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.dialogFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* A shipped composition can be long; the dialog scrolls it rather than grow. */
|
||||
.viewerCode {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: min(52vh, 480px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteDialog {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.deleteConfirm:not(:disabled) {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteConfirm:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
|
||||
/* The conversational authoring entry, after the card grid in the spot the
|
||||
create button vacated. Dashed like the Models page's add affordances: it
|
||||
reads as a place a preset will appear, not a command. */
|
||||
.creatorButton {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creatorButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.creatorButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Agent-presets settings section: the roster as cards, a copy dialog as the
|
||||
* only way a preset is created, and a read-only viewer over the shipped
|
||||
* compositions.
|
||||
*
|
||||
* The browser edits no composition text — a shipped preset opens read-only to
|
||||
* be READ (it is the known-good composition a copy starts from), and a custom
|
||||
* preset is edited in its own files, which is what the location action leads
|
||||
* to. Deleting a preset leaves running sessions alone: a composition is
|
||||
* mounted once at session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
/** Registration-side business face for the management section. */
|
||||
export interface AgentPresetSectionInjected {
|
||||
hooks: {
|
||||
/** Page snapshot bound by the renderer as useAgentPresetSection. */
|
||||
agentPresetSection: SnapshotStore<AgentPresetSectionState>
|
||||
}
|
||||
/** Read the roster; called once when the section first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Open one shipped preset's composition in the read-only viewer. */
|
||||
view: (id: string) => Promise<void>
|
||||
/** Close the read-only viewer. */
|
||||
closeView: () => void
|
||||
/** Open the copy dialog over one preset. */
|
||||
beginCopy: (from: string) => void
|
||||
/** Close the copy dialog, discarding the draft. */
|
||||
cancelCopy: () => void
|
||||
/** Name the preset the copy creates. */
|
||||
setCopyId: (id: string) => void
|
||||
/** Name the copy's display name. */
|
||||
setCopyName: (name: string) => void
|
||||
/** Submit the copy. */
|
||||
confirmCopy: () => Promise<void>
|
||||
/** Open one preset's directory, or reveal its path where there is no desktop. */
|
||||
openLocation: (id: string) => Promise<void>
|
||||
/**
|
||||
* Stage the self-referential preset and start a new session on it — the
|
||||
* guided way to author a preset, beside copying. Absent when the surface
|
||||
* is composed without the conversation flow to land the session in.
|
||||
*/
|
||||
startCreatorDraft?: () => void
|
||||
/** Ask for delete confirmation, or dismiss it with null. */
|
||||
confirmDelete: (id: string | null) => void
|
||||
/** Delete the preset awaiting confirmation. */
|
||||
remove: () => Promise<void>
|
||||
/** Make one preset the default for sessions created later. */
|
||||
makeDefault: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSectionInjected>
|
||||
|
||||
/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */
|
||||
interface CopyDialogProps {
|
||||
state: AgentPresetSectionState
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
actions: Pick<AgentPresetSectionInjected,
|
||||
'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'>
|
||||
}
|
||||
|
||||
function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
|
||||
const draft = state.copy
|
||||
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
||||
const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker))
|
||||
return (
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
onClose={() => { actions.cancelCopy() }}
|
||||
title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`}
|
||||
closeLabel={t('close')}
|
||||
description={t('copyIntro')}
|
||||
className={css.dialog as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={draft?.saving === true}
|
||||
onClick={() => { actions.cancelCopy() }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={draft === null || draft.saving || blocker !== undefined}
|
||||
onClick={() => { void actions.confirmCopy() }}
|
||||
>
|
||||
{draft?.saving === true ? t('creating') : t('create')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{draft === null
|
||||
? null
|
||||
: (
|
||||
<div className={css.dialogFields}>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('presetId')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.id}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t('presetIdPlaceholder')}
|
||||
onChange={(event) => { actions.setCopyId(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('displayName')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.name}
|
||||
spellCheck={false}
|
||||
placeholder={t('displayNamePlaceholder')}
|
||||
onChange={(event) => { actions.setCopyName(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Agent presets section content column.
|
||||
* @param props - composed slot props.
|
||||
* @returns the section, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const { useAgentPresetSection, t, load } = props
|
||||
const state = useAgentPresetSection(snapshot => snapshot)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// A deployment that composes no presets has nothing to manage: every
|
||||
// session shares the host composition and the page would be an empty list.
|
||||
if (state.status === 'unavailable') return null
|
||||
if (state.status === 'error') {
|
||||
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
|
||||
const detail = state.error ?? ''
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<p className={css.error} role="alert">{`${t('error')} ${detail}`}</p>
|
||||
<button type="button" className={css.secondaryButton} onClick={() => { void load() }}>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
<p className={css.intro}>{t('sectionIntro')}</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
|
||||
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
|
||||
const group = state.rows.filter(row => row.trust === trust)
|
||||
if (group.length === 0) return null
|
||||
return (
|
||||
<section key={trust} className={css.group}>
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
<ul className={css.cards}>
|
||||
{group.map(row => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
common act, so it should not hide behind a small button.
|
||||
The action row sits outside it — nesting buttons is
|
||||
invalid, and these act on the card rather than select it.
|
||||
A broken preset cannot compose a session, so its body is
|
||||
disabled and the card says why instead of offering it. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{row.name ?? row.id}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
from, so READING one is the point; a custom preset is
|
||||
edited in its files instead, which the location action
|
||||
leads to. A broken shipped preset has no readable
|
||||
composition to offer, so its viewer is withheld; a
|
||||
broken custom one keeps the location action — the
|
||||
files are where it gets fixed. */}
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */}
|
||||
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<CopyDialog
|
||||
state={state}
|
||||
t={t}
|
||||
actions={{
|
||||
cancelCopy: props.cancelCopy,
|
||||
confirmCopy: props.confirmCopy,
|
||||
setCopyId: props.setCopyId,
|
||||
setCopyName: props.setCopyName,
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={state.view !== null}
|
||||
onClose={() => { props.closeView() }}
|
||||
title={state.view === null ? '' : `${t('view')} · ${state.view.title}`}
|
||||
closeLabel={t('close')}
|
||||
description={t('composition')}
|
||||
className={css.dialog as string}
|
||||
footer={(
|
||||
<Button variant="outline" autoFocus onClick={() => { props.closeView() }}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{state.view === null
|
||||
? null
|
||||
: <pre className={css.viewerCode}>{state.view.content}</pre>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={state.pendingDelete !== null}
|
||||
onClose={() => { props.confirmDelete(null) }}
|
||||
title={t('deleteTitle')}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
className={css.deleteDialog as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
autoFocus
|
||||
disabled={state.deleting}
|
||||
onClick={() => { props.confirmDelete(null) }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteConfirm}
|
||||
disabled={state.deleting}
|
||||
onClick={() => { void props.remove() }}
|
||||
>
|
||||
{state.deleting ? t('deleting') : t('deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
packages/client/ui-agent-preset/src/client/PresetMenu.tsx
Normal file
83
packages/client/ui-agent-preset/src/client/PresetMenu.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The preset picker both surfaces render: a menu of presets over a button
|
||||
* naming the current one.
|
||||
*
|
||||
* The settings row and the composer seat differ in where they sit, what they
|
||||
* call the current value, and when they refuse a pick — not in how the picker
|
||||
* itself behaves. Trust is the one thing the list always says: a locally
|
||||
* authored preset is exactly as privileged as the plugins it names, so the
|
||||
* label marks it rather than presenting every preset as shipped and vetted.
|
||||
*/
|
||||
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** What one surface passes to the shared picker. */
|
||||
export interface PresetMenuProps {
|
||||
/** Presets to offer, in roster order. */
|
||||
options: readonly AgentPresetOption[]
|
||||
/** The preset the button names and the menu marks selected. */
|
||||
selectedId: string
|
||||
/** Text on the button; the surfaces word a pending roster differently. */
|
||||
label: string
|
||||
/** Suffix marking a locally authored preset in the menu. */
|
||||
userTrustLabel: string
|
||||
/** Class for the trigger button, owned by the calling surface. */
|
||||
buttonClassName: string | undefined
|
||||
/** Class for the chevron, owned by the calling surface. */
|
||||
chevronClassName: string | undefined
|
||||
/** Whether the trigger refuses interaction. */
|
||||
disabled: boolean
|
||||
/** Whether the menu is open — the surface owns this so it can force it shut. */
|
||||
open: boolean
|
||||
/** Report the menu's next open state. */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Called with the picked preset once the menu has closed. */
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the preset picker.
|
||||
* @param props - the calling surface's copy, styling, and handlers.
|
||||
* @returns the menu and its trigger.
|
||||
*/
|
||||
export function PresetMenu({
|
||||
options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName,
|
||||
disabled, open, onOpenChange, onSelect,
|
||||
}: PresetMenuProps) {
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { onOpenChange(false) }}
|
||||
items={options.map(option => ({
|
||||
id: option.id,
|
||||
// The metadata name is what every surface shows; the id is addressing,
|
||||
// not a label. A preset that names itself nothing falls back to its id,
|
||||
// which is then all there is to say about it.
|
||||
label: option.trust === 'user'
|
||||
? `${option.name ?? option.id} · ${userTrustLabel}`
|
||||
: option.name ?? option.id,
|
||||
}))}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
onOpenChange(false)
|
||||
onSelect(id)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClassName}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => { onOpenChange(!open) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={chevronClassName} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
209
packages/client/ui-agent-preset/src/client/index.ts
Normal file
209
packages/client/ui-agent-preset/src/client/index.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, browser half — four surfaces over one roster:
|
||||
* a General-settings row for the default preset, a chip on the new-session
|
||||
* screen for the session about to start, a read-only label in the session
|
||||
* header, and a settings section that manages the roster (copy, delete,
|
||||
* default, and the way into a preset's own files).
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset). That is what splits
|
||||
* the choice from the display: the General row and the hero chip are both
|
||||
* before-the-fact, while the header only reports what a session already runs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSection } from './AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
import type { SeatSessionSummary } from './seat-store.ts'
|
||||
import { AgentPresetSectionController } from './section-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
|
||||
|
||||
export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx'
|
||||
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
|
||||
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView,
|
||||
} from './section-store.ts'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Mount the General-settings row.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new AgentPresetSettingsController(api)
|
||||
// One roster, four surfaces. The chip is registered in a later scope, so it
|
||||
// subscribes here rather than being reached from this one.
|
||||
const rosterReaders = new Set<() => void>()
|
||||
const section = new AgentPresetSectionController(api, () => {
|
||||
void controller.load()
|
||||
for (const read of rosterReaders) read()
|
||||
})
|
||||
|
||||
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
|
||||
|
||||
const injected = (): AgentPresetRowInjected => ({
|
||||
hooks: { agentPreset: controller.store },
|
||||
load: () => controller.load(),
|
||||
select: (id: string) => controller.select(id),
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
// The roster is a live directory and the default is a settings field, so
|
||||
// both an external settings edit and a reconnect can move this row.
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void controller.load()
|
||||
// The section reads the same roster and marks the same default, so a
|
||||
// change made from either surface converges both.
|
||||
if (section.store.getSnapshot().status !== 'idle') void section.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-agent-preset: settings refresh')
|
||||
|
||||
// The settings section's conversational authoring entry: stage the
|
||||
// self-referential preset and land a new session on it. Bound inside the
|
||||
// conversation scope below (the seat and the session flow live there) and
|
||||
// unbound with it, so the section's face reads the current binding per
|
||||
// render and simply hides the button while no flow exists.
|
||||
let creatorDraft: (() => void) | undefined
|
||||
|
||||
// The new-session chip and the header label: one controller, because the
|
||||
// staged choice belongs to the flow rather than to any one session.
|
||||
ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => {
|
||||
const api = (scope.get('connection') as ConnectionHandle).api
|
||||
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
|
||||
const state = scope.sessions.list.getSnapshot()
|
||||
const summary = state.current === undefined ? undefined : state.byId[state.current]
|
||||
return summary === undefined
|
||||
? undefined
|
||||
: {
|
||||
id: summary.id,
|
||||
blank: summary.blank,
|
||||
...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset },
|
||||
}
|
||||
}, (sessionId, agentPreset) => {
|
||||
scope.sessions.noteAgentPreset(sessionId as never, agentPreset)
|
||||
})
|
||||
|
||||
const seatInjected = (): AgentPresetSeatInjected => ({
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
})
|
||||
|
||||
const labelInjected = (): AgentPresetLabelInjected => ({
|
||||
hooks: { agentPresets: controller.store },
|
||||
load: () => controller.load(),
|
||||
})
|
||||
|
||||
scope.effect(() => {
|
||||
// Connecting a workspace either creates a blank session or reuses one,
|
||||
// and either way the chip's pick predates it — so the stage is applied
|
||||
// when the session arrives, not when it was made.
|
||||
const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
|
||||
// The chip opens on the deployment default, so a default changed from
|
||||
// the settings surface moves it too — otherwise the screen that starts
|
||||
// the next session keeps offering the previous default until a reload,
|
||||
// which is exactly the session the setting claims to govern. A staged
|
||||
// pick survives: `load()` prefers it over the refreshed fallback.
|
||||
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void seat.load()
|
||||
})
|
||||
// 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
|
||||
// a preset authored to be used is missing from the one place it is used.
|
||||
const readRoster = (): void => { void seat.load() }
|
||||
rosterReaders.add(readRoster)
|
||||
// Stage WITHOUT applying — the still-current running session would
|
||||
// refuse the swap and drop the stage — then start the session it lands
|
||||
// on: the chip's list-change applier composes the blank session the
|
||||
// workspace connect produces or reuses.
|
||||
creatorDraft = () => {
|
||||
seat.stage('cordis')
|
||||
scope.workspaces.startSession()
|
||||
}
|
||||
const chip = scope.slots.register({
|
||||
name: 'conversation.hero.agentPreset',
|
||||
locale: 'settings.agentPreset',
|
||||
inject: seatInjected,
|
||||
}, AgentPresetSeat)
|
||||
const label = scope.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'agent-preset',
|
||||
order: 20,
|
||||
locale: 'settings.agentPreset',
|
||||
inject: labelInjected,
|
||||
}, AgentPresetLabel)
|
||||
return () => {
|
||||
stop()
|
||||
settingsMoved()
|
||||
rosterReaders.delete(readRoster)
|
||||
creatorDraft = undefined
|
||||
chip()
|
||||
label()
|
||||
}
|
||||
}, 'ui-agent-preset: new-session chip and header label')
|
||||
})
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
hooks: { agentPresetSection: section.store },
|
||||
load: () => section.load(),
|
||||
view: (id: string) => section.view(id),
|
||||
closeView: () => { section.closeView() },
|
||||
beginCopy: (from: string) => { section.beginCopy(from) },
|
||||
cancelCopy: () => { section.cancelCopy() },
|
||||
setCopyId: (id: string) => { section.setCopyId(id) },
|
||||
setCopyName: (name: string) => { section.setCopyName(name) },
|
||||
confirmCopy: () => section.confirmCopy(),
|
||||
openLocation: (id: string) => section.openLocation(id),
|
||||
...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft },
|
||||
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
|
||||
remove: () => section.remove(),
|
||||
makeDefault: (id: string) => section.makeDefault(id),
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'agent-preset',
|
||||
order: -25,
|
||||
locale: 'settings.agentPreset',
|
||||
inject: injected,
|
||||
}, AgentPresetRow))
|
||||
// Ordered after Models: choosing a model is routine, and composing an
|
||||
// agent is the deployment-shaping act behind it.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'agent-presets',
|
||||
order: 20,
|
||||
label: () => ctx.locale.bind('settings.agentPreset')('nav'),
|
||||
locale: 'settings.agentPreset',
|
||||
inject: sectionInjected,
|
||||
}, AgentPresetSection))
|
||||
}
|
||||
118
packages/client/ui-agent-preset/src/client/locales.ts
Normal file
118
packages/client/ui-agent-preset/src/client/locales.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view'
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
| 'brokenBadge' | 'brokenNoCopy'
|
||||
| 'composition' | 'cancel' | 'close' | 'retry'
|
||||
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
|
||||
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
|
||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
title: 'Agent preset',
|
||||
description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.',
|
||||
loading: 'Loading presets…',
|
||||
error: 'Could not load agent presets.',
|
||||
userTrust: 'Custom',
|
||||
seatHint: 'Agent preset for the session you are about to start',
|
||||
headerHint: 'The agent preset this session runs, fixed when it started',
|
||||
nav: 'Agent presets',
|
||||
sectionIntro:
|
||||
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
|
||||
+ 'Duplicate an existing one and make it yours, or let the agent draft one for you in Creator mode.',
|
||||
builtIn: 'Built-in',
|
||||
setDefault: 'Set as default',
|
||||
view: 'View',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||
delete: 'Delete',
|
||||
presetId: 'Identifier',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: 'Name',
|
||||
displayNamePlaceholder: 'Shown in the picker; defaults to the identifier',
|
||||
inUse: 'In use',
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
brokenBadge: 'Broken',
|
||||
brokenNoCopy: 'Broken presets cannot be duplicated',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
retry: 'Retry',
|
||||
copyTitle: 'Duplicate preset',
|
||||
copyIntro:
|
||||
'The whole preset is copied on this machine. The identifier becomes its directory name and cannot '
|
||||
+ 'be changed later; everything else is edited in the preset\'s own files.',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
creatorDraft: 'Draft a custom preset with Creator mode',
|
||||
openLocation: 'Open folder',
|
||||
showLocation: 'Show location',
|
||||
revealedPathLabel: 'Preset files:',
|
||||
idRequired: 'Give the preset an identifier.',
|
||||
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
|
||||
idTaken: 'A preset with this identifier already exists.',
|
||||
deleteTitle: 'Delete this preset?',
|
||||
deleteDescription:
|
||||
'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
deleteConfirm: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
title: 'Agent 预设',
|
||||
description: '对此后新建的会话生效。运行中的会话保持它开始时的预设。',
|
||||
loading: '正在加载预设…',
|
||||
error: '无法加载 Agent 预设。',
|
||||
userTrust: '自定义',
|
||||
seatHint: '即将开始的这个会话所用的 Agent 预设',
|
||||
headerHint: '本会话运行的 Agent 预设,开始时即固定',
|
||||
nav: 'Agent 预设',
|
||||
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。',
|
||||
builtIn: '内置',
|
||||
setDefault: '设为默认',
|
||||
view: '查看',
|
||||
duplicate: '复制',
|
||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||
delete: '删除',
|
||||
presetId: '标识符',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: '名称',
|
||||
displayNamePlaceholder: '选择器中显示的名字,缺省用标识符',
|
||||
inUse: '当前使用',
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
brokenBadge: '已损坏',
|
||||
brokenNoCopy: '预设已损坏,无法复制',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
retry: '重试',
|
||||
copyTitle: '复制预设',
|
||||
copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。',
|
||||
create: '创建',
|
||||
creating: '正在创建…',
|
||||
creatorDraft: '用「创造模式」创作自定义预设',
|
||||
openLocation: '打开目录',
|
||||
showLocation: '查看路径',
|
||||
revealedPathLabel: '预设文件:',
|
||||
idRequired: '请填写标识符。',
|
||||
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
|
||||
idTaken: '该标识符已被占用。',
|
||||
deleteTitle: '删除该预设?',
|
||||
deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
}
|
||||
163
packages/client/ui-agent-preset/src/client/seat-store.ts
Normal file
163
packages/client/ui-agent-preset/src/client/seat-store.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Hero-chip controller: which preset the NEXT session gets.
|
||||
*
|
||||
* The new-session screen has no session, so a pick is staged rather than
|
||||
* applied. It reaches a session when one becomes current and is still blank —
|
||||
* whether the workspace connect created it or reused an existing blank one,
|
||||
* which is why staging cannot simply ride along on `sessions.create`.
|
||||
*
|
||||
* The stage is forgotten once applied: the next new session starts from the
|
||||
* deployment default again, matching the workspace picker beside it.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
createSnapshotStore, type SessionId, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { messageOf, presetOptions } from './settings-store.ts'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Hero-chip snapshot. */
|
||||
export interface AgentPresetSeatState {
|
||||
/** Presets the deployment supplies; empty means the chip renders nothing. */
|
||||
options: readonly AgentPresetOption[]
|
||||
/** The staged choice, empty until the roster loads. */
|
||||
current: string
|
||||
/** A rejected apply's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', error: null, busy: false,
|
||||
}
|
||||
|
||||
/** One session's identity and whether it has started. */
|
||||
export interface SeatSessionSummary {
|
||||
/** The session the chip would apply its staged choice to. */
|
||||
id: SessionId
|
||||
/** False once a turn has run — applying is refused from then on. */
|
||||
blank: boolean
|
||||
/** The preset the session already runs, when the summary reports one. */
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
/** Stages the next session's preset and applies it when one appears. */
|
||||
export class AgentPresetSeatController {
|
||||
/** Chip snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
|
||||
|
||||
/**
|
||||
* The deployment default, so a consumed stage can fall back to it without
|
||||
* re-reading the roster.
|
||||
*/
|
||||
private fallback = ''
|
||||
|
||||
/** Set while a pick is waiting for a session; cleared once applied. */
|
||||
private staged: string | undefined
|
||||
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'agentPresets'>,
|
||||
/** The session the hero is about to hand over to, when there is one. */
|
||||
private readonly currentSession: () => SeatSessionSummary | undefined,
|
||||
/**
|
||||
* Publish an applied switch into the session list, so the header label
|
||||
* moves with the composition instead of waiting for the next full list
|
||||
* refresh. Optional: a harness that renders no list omits it.
|
||||
*/
|
||||
private readonly onApplied?: (sessionId: string, agentPreset: string) => void,
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSeatState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the roster and open the chip on the deployment default.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.agentPresets.list({})
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { presets } = response.result.value
|
||||
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
|
||||
this.set({
|
||||
options: presetOptions(presets),
|
||||
// Staged pick first, then the composition the current session
|
||||
// already carries, then the deployment default. The middle term is
|
||||
// what keeps a late-landing load from regressing the display after
|
||||
// an applied stage was consumed — the chip mounts (and loads) only
|
||||
// once the flow's session is current, so the reply can arrive after
|
||||
// apply() already composed it.
|
||||
current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage one preset for the next session, applying it immediately when a
|
||||
* blank session is already current.
|
||||
* @param id - the preset to stage.
|
||||
* @returns once the stage settled, and the apply too when one happened.
|
||||
*/
|
||||
async select(id: string): Promise<void> {
|
||||
if (this.store.getSnapshot().busy) return
|
||||
this.stage(id)
|
||||
await this.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a pick WITHOUT the immediate apply, for a flow that starts the
|
||||
* receiving session after the pick (the settings section's creator entry).
|
||||
* `select()`'s immediate apply would meet the still-current running session
|
||||
* and drop the stage as unservable; staging alone leaves it for the
|
||||
* list-change applier, which fires when the started session becomes
|
||||
* current.
|
||||
* @param id - the preset to stage.
|
||||
*/
|
||||
stage(id: string): void {
|
||||
this.staged = id
|
||||
this.set({ current: id, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the staged choice to the current session, if there is one to take it.
|
||||
*
|
||||
* Called both by `select()` and by whoever observes the current session
|
||||
* changing, because the session may appear either before or after the pick.
|
||||
* @returns once the switch settled, or immediately when there is nothing to do.
|
||||
*/
|
||||
async apply(): Promise<void> {
|
||||
const staged = this.staged
|
||||
const session = this.currentSession()
|
||||
if (staged === undefined || session === undefined) return
|
||||
// A started session's history was produced under its own composition; the
|
||||
// host refuses the swap, so the stage is no longer meaningful.
|
||||
if (!session.blank || session.agentPreset === staged) {
|
||||
this.staged = undefined
|
||||
return
|
||||
}
|
||||
this.set({ busy: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
|
||||
this.staged = undefined
|
||||
if (!response.result.ok) {
|
||||
this.set({ busy: false, error: response.result.error.message, current: this.fallback })
|
||||
return
|
||||
}
|
||||
// Consumed: the next new session opens on the deployment default again.
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
this.onApplied?.(session.id, response.result.value.agentPreset)
|
||||
} catch (error) {
|
||||
this.staged = undefined
|
||||
this.set({ busy: false, error: messageOf(error), current: this.fallback })
|
||||
}
|
||||
}
|
||||
}
|
||||
347
packages/client/ui-agent-preset/src/client/section-store.ts
Normal file
347
packages/client/ui-agent-preset/src/client/section-store.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Agent-preset management controller: the roster as a list, a copy dialog as
|
||||
* the only way a preset is created, and a read-only viewer over the shipped
|
||||
* compositions.
|
||||
*
|
||||
* The browser edits no composition text. A new preset is a host-side copy of
|
||||
* an existing one (`{ from, id, name? }` is all that crosses the wire), and
|
||||
* everything after creation happens in the preset's own files — which is why
|
||||
* the page's other job is getting the user TO those files: open the directory
|
||||
* where the host has a desktop, show its path where it does not.
|
||||
*
|
||||
* The host stays the single fact source. Every mutation writes through the
|
||||
* wire and the page re-reads the roster afterwards, because a copy changes
|
||||
* more than the row it targeted.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Ids a preset directory may be named, mirroring the host's own rule. */
|
||||
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** One preset row the page renders. */
|
||||
export interface PresetRow {
|
||||
/** Preset id and directory name; the display name falls back to it. */
|
||||
id: string
|
||||
/** Display name the preset published, absent when it published none. */
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
isDefault: boolean
|
||||
/**
|
||||
* Why the preset cannot compose a session, absent when it can. A broken
|
||||
* row renders marked and unselectable — its directory still occupies the
|
||||
* id, so deleting it (or fixing the files) is the way out, and this page
|
||||
* is where both of those live.
|
||||
*/
|
||||
broken?: string
|
||||
}
|
||||
|
||||
/** The copy dialog: a new id and optional display name over a fixed source. */
|
||||
export interface CopyDraft {
|
||||
/** The preset being copied. */
|
||||
from: string
|
||||
/** Display name of the source, for the dialog title. */
|
||||
fromTitle: string
|
||||
/** New preset id being typed; the directory name, so it is required. */
|
||||
id: string
|
||||
/** Display name being typed; empty falls back to the id. */
|
||||
name: string
|
||||
/** Whether the copy is in flight. */
|
||||
saving: boolean
|
||||
/** The last copy failure, cleared by the next edit. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** The read-only composition viewer over one shipped preset. */
|
||||
export interface PresetView {
|
||||
/** The preset whose composition is shown. */
|
||||
id: string
|
||||
/** Display name, for the dialog title. */
|
||||
title: string
|
||||
/** Composition text exactly as stored. */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
export interface AgentPresetSectionState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
|
||||
/** Whole-load failure text; a copy failure stays on the dialog. */
|
||||
error: string | null
|
||||
/** Whether the deployment configures a root new presets can be written to. */
|
||||
authorable: boolean
|
||||
/** Whether the host can open a preset directory on a native desktop. */
|
||||
hasDocument: boolean
|
||||
/** Every preset the deployment currently supplies. */
|
||||
rows: readonly PresetRow[]
|
||||
/** The open copy dialog, or null. */
|
||||
copy: CopyDraft | null
|
||||
/** The open read-only viewer, or null. */
|
||||
view: PresetView | null
|
||||
/** The preset awaiting delete confirmation. */
|
||||
pendingDelete: string | null
|
||||
/** Whether a delete is in flight. */
|
||||
deleting: boolean
|
||||
/**
|
||||
* Preset directories shown as text because the host has no desktop opener
|
||||
* — the answer `openDocument` gives instead of opening.
|
||||
*/
|
||||
revealedPaths: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSectionState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
authorable: false,
|
||||
hasDocument: false,
|
||||
rows: [],
|
||||
copy: null,
|
||||
view: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
revealedPaths: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this copy cannot be submitted yet, as a locale key, or undefined when
|
||||
* it can. Client-side only: the host re-checks the id and its answer is what
|
||||
* the dialog reports on failure.
|
||||
* @param draft - the open copy dialog.
|
||||
* @param rows - the roster, for the collision check.
|
||||
* @returns the blocking reason's locale key, or undefined when submittable.
|
||||
*/
|
||||
export function draftBlocker(
|
||||
draft: CopyDraft,
|
||||
rows: readonly PresetRow[],
|
||||
): 'idRequired' | 'idInvalid' | 'idTaken' | undefined {
|
||||
if (draft.id === '') return 'idRequired'
|
||||
if (!PRESET_ID.test(draft.id)) return 'idInvalid'
|
||||
// A copy never overwrites: landing on a name already in use would replace
|
||||
// something the user did not open.
|
||||
if (rows.some(row => row.id === draft.id)) return 'idTaken'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reads the roster and drives the copy dialog, viewer, and location reveals. */
|
||||
export class AgentPresetSectionController {
|
||||
/** Page snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>,
|
||||
/**
|
||||
* Called after this page changes the roster DIRECTORY, so the other
|
||||
* surfaces reading the same roster re-read it. A settings field moving is
|
||||
* already announced by the host through `settings/changed`; a directory
|
||||
* copied or deleted here is not, and the new-session chip has no other
|
||||
* way to learn a preset it should offer now exists.
|
||||
*/
|
||||
private readonly rosterChanged: () => void = () => {},
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSectionState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
private patchCopy(patch: Partial<CopyDraft>): void {
|
||||
const { copy } = this.store.getSnapshot()
|
||||
if (copy === null) return
|
||||
this.set({ copy: { ...copy, ...patch } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster. An empty roster means the deployment composes no
|
||||
* presets, which is a valid deployment rather than a failure — the section
|
||||
* reports `unavailable` and renders nothing.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const roster = await beginRosterRead(this.api, this.store)
|
||||
if (roster === undefined) return
|
||||
const { presets, authorable, hasDocument } = roster
|
||||
if (presets.length === 0) {
|
||||
// Nothing to manage leaves nothing to keep a dialog open over.
|
||||
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
|
||||
return
|
||||
}
|
||||
// A reveal outlives a reload but not its preset: a path for a row the
|
||||
// roster no longer lists would be a claim about a directory that is gone.
|
||||
const revealed = this.store.getSnapshot().revealedPaths
|
||||
const kept = Object.fromEntries(
|
||||
Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id)))
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
authorable,
|
||||
hasDocument,
|
||||
rows: presets.map(preset => ({ ...preset })),
|
||||
revealedPaths: kept,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one shipped preset's composition in the read-only viewer.
|
||||
* @param id - the preset to view.
|
||||
* @returns once the composition loaded or the failure is on the page.
|
||||
*/
|
||||
async view(id: string): Promise<void> {
|
||||
this.set({ error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.read({ agentPreset: id })
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { name, content } = response.result.value
|
||||
this.set({ view: { id, title: name ?? id, content } })
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the read-only viewer. */
|
||||
closeView(): void {
|
||||
this.set({ view: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the copy dialog over one preset.
|
||||
* @param from - the preset the copy will start from.
|
||||
*/
|
||||
beginCopy(from: string): void {
|
||||
const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from)
|
||||
this.set({
|
||||
error: null,
|
||||
copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null },
|
||||
})
|
||||
}
|
||||
|
||||
/** Close the copy dialog, discarding whatever was typed. */
|
||||
cancelCopy(): void {
|
||||
this.set({ copy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the preset the copy creates.
|
||||
* @param id - the id typed into the dialog.
|
||||
*/
|
||||
setCopyId(id: string): void {
|
||||
this.patchCopy({ id, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the copy's display name.
|
||||
* @param name - the display name typed into the dialog.
|
||||
*/
|
||||
setCopyName(name: string): void {
|
||||
this.patchCopy({ name, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the copy, re-read the roster, then take the user to the new
|
||||
* preset's files — the directory opens where the host has a desktop, and
|
||||
* its path appears on the new row where it does not.
|
||||
* @returns once the copy settled and the page reflects it.
|
||||
*/
|
||||
async confirmCopy(): Promise<void> {
|
||||
const draft = this.store.getSnapshot().copy
|
||||
if (draft === null || draft.saving) return
|
||||
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
|
||||
this.patchCopy({ saving: true, error: null })
|
||||
try {
|
||||
const name = draft.name.trim()
|
||||
const response = await this.api.agentPresets.copy({
|
||||
from: draft.from,
|
||||
agentPreset: draft.id,
|
||||
...name === '' ? {} : { name },
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
this.patchCopy({ saving: false, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({ copy: null })
|
||||
await this.load()
|
||||
this.rosterChanged()
|
||||
// A preset is its files from here on (the dialog collected nothing
|
||||
// else), so landing in them is the completion, not a follow-up.
|
||||
await this.openLocation(draft.id)
|
||||
} catch (error) {
|
||||
this.patchCopy({ saving: false, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one preset's directory on the host desktop, or reveal its path on
|
||||
* the row where the deployment has no opener to hand it to.
|
||||
* @param id - the preset whose files the user wants.
|
||||
* @returns once the host answered and the page reflects it.
|
||||
*/
|
||||
async openLocation(id: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.agentPresets.openDocument({ agentPreset: id })
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
if (response.result.value.opened) return
|
||||
const { path } = response.result.value
|
||||
this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for confirmation before deleting one preset.
|
||||
* @param id - the preset to delete, or null to dismiss the confirmation.
|
||||
*/
|
||||
confirmDelete(id: string | null): void {
|
||||
if (this.store.getSnapshot().deleting) return
|
||||
this.set({ pendingDelete: id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the preset awaiting confirmation, then re-read the roster.
|
||||
*
|
||||
* A session already composed from it keeps running: its composition was
|
||||
* mounted at creation and nothing re-reads the file.
|
||||
* @returns once the delete settled and the page reflects it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
const { pendingDelete, deleting } = this.store.getSnapshot()
|
||||
if (pendingDelete === null || deleting) return
|
||||
this.set({ deleting: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
|
||||
if (!response.result.ok) {
|
||||
this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({ deleting: false, pendingDelete: null })
|
||||
await this.load()
|
||||
this.rosterChanged()
|
||||
} catch (error) {
|
||||
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one preset the default for sessions created later. Running sessions
|
||||
* keep the composition they began with, so this never disturbs work.
|
||||
* @param id - the preset to make default.
|
||||
* @returns once the write settled and the roster was re-read.
|
||||
*/
|
||||
async makeDefault(id: string): Promise<void> {
|
||||
const failure = await writeDefaultPreset(this.api, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ error: failure })
|
||||
return
|
||||
}
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
255
packages/client/ui-agent-preset/src/client/settings-store.ts
Normal file
255
packages/client/ui-agent-preset/src/client/settings-store.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Agent-preset default-settings controller.
|
||||
*
|
||||
* Options and the current default both come from one `agentPreset.list` call:
|
||||
* the roster already reports which id a session with no explicit choice gets,
|
||||
* so the row needs no schema introspection. Writes target the settings
|
||||
* namespace's `default` field, which is what the host resolves at creation.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** The agent-preset settings namespace on the host wire. */
|
||||
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
|
||||
/**
|
||||
* Human text for a rejected wire call. A transport failure rejects with an
|
||||
* Error; a host or a runtime can reject with anything, and the surface still
|
||||
* has to say something.
|
||||
* @param error - the rejection value.
|
||||
* @returns the message to show.
|
||||
*/
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for sessions created later.
|
||||
*
|
||||
* The default is a settings field rather than a preset property, so both the
|
||||
* General row and the management section write it here — one home for which
|
||||
* namespace and field the host resolves at session creation.
|
||||
* @param api - the settings wire face.
|
||||
* @param id - the preset to make default.
|
||||
* @returns the failure message, or undefined once the write landed.
|
||||
*/
|
||||
export async function writeDefaultPreset(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
id: string,
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able to
|
||||
// say so instead of the row silently snapping back.
|
||||
return messageOf(error)
|
||||
}
|
||||
return response.result.ok ? undefined : response.result.error.message
|
||||
}
|
||||
|
||||
/** One selectable preset. */
|
||||
export interface AgentPresetOption {
|
||||
/** Preset id, written to Settings and the label's fallback. */
|
||||
id: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Display name the preset published, absent when it published none. */
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** One roster entry exactly as the host reports it. */
|
||||
export interface RosterPreset {
|
||||
/** Preset id and directory name. */
|
||||
id: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
isDefault: boolean
|
||||
/** Display name the preset published, absent when it published none. */
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
/** Why the preset cannot compose a session, absent when it can. */
|
||||
broken?: string
|
||||
}
|
||||
|
||||
/** The roster the host answered with. */
|
||||
export interface RosterValue {
|
||||
/** Every preset the deployment composes, in the order the host lists them. */
|
||||
presets: readonly RosterPreset[]
|
||||
/** Whether this browser may author presets at all. */
|
||||
authorable: boolean
|
||||
/** Whether the host can open a preset directory on a native desktop. */
|
||||
hasDocument: boolean
|
||||
}
|
||||
|
||||
/** The roster, or the message to show in its place. */
|
||||
export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Read the roster, folding both refusal shapes into one message.
|
||||
*
|
||||
* The wire refuses in two ways — the transport rejects, or it answers an
|
||||
* `ok: false` envelope — and every surface treats them identically. Folding
|
||||
* them here keeps each store's `load` about what it does with a roster rather
|
||||
* than about how the call can fail.
|
||||
* @param api - the agent-preset wire face.
|
||||
* @returns the roster, or the message to show in its place.
|
||||
*/
|
||||
export async function readRoster(api: Pick<IApiClient, 'agentPresets'>): Promise<RosterRead> {
|
||||
try {
|
||||
const response = await api.agentPresets.list({})
|
||||
return response.result.ok
|
||||
? { ok: true, value: response.result.value }
|
||||
: { ok: false, error: response.result.error.message }
|
||||
} catch (error) {
|
||||
return { ok: false, error: messageOf(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening move every roster-backed surface makes: refuse a read that is
|
||||
* already in flight, mark the store loading, then read.
|
||||
*
|
||||
* A surface that gets `undefined` returns without touching its snapshot
|
||||
* further — either another read owns it, or this one already wrote the
|
||||
* failure. What differs between surfaces starts after this.
|
||||
* @param api - the agent-preset wire face.
|
||||
* @param store - the surface's own snapshot store.
|
||||
* @returns the roster, or undefined when the caller should return.
|
||||
*/
|
||||
export async function beginRosterRead<S extends { status: string; error: string | null }>(
|
||||
api: Pick<IApiClient, 'agentPresets'>,
|
||||
store: SnapshotStore<S>,
|
||||
): Promise<RosterValue | undefined> {
|
||||
const before = store.getSnapshot()
|
||||
if (before.status === 'loading') return undefined
|
||||
store.set({ ...before, status: 'loading', error: null })
|
||||
const roster = await readRoster(api)
|
||||
if (roster.ok) return roster.value
|
||||
store.set({ ...store.getSnapshot(), status: 'error', error: roster.error })
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster entries as the pickers render them: healthy presets only.
|
||||
*
|
||||
* The chip and the row exist to choose the NEXT session's composition, and a
|
||||
* broken preset cannot compose one — offering it would defer the discovery
|
||||
* of that fact to a failed session start. The management section renders the
|
||||
* full roster (broken rows included) from its own store instead.
|
||||
*
|
||||
* The chip, the row, and the management section all show the same facts, and
|
||||
* `exactOptionalPropertyTypes` makes "absent" and "present as undefined"
|
||||
* different shapes — so the spread dance belongs in one place rather than
|
||||
* once per store.
|
||||
* @param presets - the roster the host answered with.
|
||||
* @returns one option per selectable preset, in roster order.
|
||||
*/
|
||||
export function presetOptions(
|
||||
presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[],
|
||||
): AgentPresetOption[] {
|
||||
return presets.filter(preset => preset.broken === undefined).map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
}))
|
||||
}
|
||||
|
||||
/** Agent-preset settings-row snapshot. */
|
||||
export interface AgentPresetSettingsState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
|
||||
error: string | null
|
||||
/**
|
||||
* Whether this browser may persist the choice at all. `settings.describe` is
|
||||
* loopback-only and reports a read-only provider as `writable: false`; the
|
||||
* row then shows the current default and disables the control rather than
|
||||
* offering a write the gateway will refuse.
|
||||
*/
|
||||
writable: boolean
|
||||
currentValue: string
|
||||
options: readonly AgentPresetOption[]
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSettingsState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
// Assumed until `load()` asks; a row that has not read yet renders nothing
|
||||
// interactive anyway (status 'idle').
|
||||
writable: true,
|
||||
currentValue: '',
|
||||
options: [],
|
||||
}
|
||||
|
||||
/** Reads the roster and persists the chosen default. */
|
||||
export class AgentPresetSettingsController {
|
||||
/** Row snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(private readonly api: IApiClient) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSettingsState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster. An empty roster means the deployment composes no
|
||||
* presets, which is a valid deployment rather than a failure — the row
|
||||
* reports `unavailable` and renders nothing.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const roster = await beginRosterRead(this.api, this.store)
|
||||
if (roster === undefined) return
|
||||
const { presets } = roster
|
||||
const [first] = presets
|
||||
if (first === undefined) {
|
||||
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
// The roster says what may be chosen; `settings.describe` says whether
|
||||
// this browser may write the choice down. A non-loopback browser reaches
|
||||
// neither method, so a refused describe leaves the row read-only rather
|
||||
// than offering a control whose write answers `settings-not-exposed`.
|
||||
const described = await this.api.settings.describe({})
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
writable: described.result.ok && described.result.value.writable,
|
||||
options: presetOptions(presets),
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for sessions created later. Running
|
||||
* sessions keep the composition they were created with, so this never
|
||||
* disturbs work in progress.
|
||||
* @param id - the preset to make default.
|
||||
* @returns once the write settled and the roster was re-read.
|
||||
*/
|
||||
async select(id: string): Promise<void> {
|
||||
const before = this.store.getSnapshot()
|
||||
if (before.status === 'saving' || id === before.currentValue) return
|
||||
this.set({ status: 'saving', error: null, currentValue: id })
|
||||
const failure = await writeDefaultPreset(this.api, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
|
||||
return
|
||||
}
|
||||
// Re-read rather than trust the patch: the host resolves the default
|
||||
// through the same roster the row displays.
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
4
packages/client/ui-agent-preset/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-agent-preset/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
9
packages/client/ui-agent-preset/src/index.ts
Normal file
9
packages/client/ui-agent-preset/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* General-settings row through exports["./client"], discovered from the
|
||||
* package.json dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
30
packages/client/ui-agent-preset/src/invariant.ts
Normal file
30
packages/client/ui-agent-preset/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`.
|
||||
* @module @deepseek-ai/dsh-client-ui-agent-preset/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-agent-preset-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream
|
||||
* or mutable runtime data; the roster and the settings write are host contracts covered there.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
546
packages/client/ui-agent-preset/tests/apply.spec.ts
Normal file
546
packages/client/ui-agent-preset/tests/apply.spec.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Registration: the General row, the settings section, the new-session chip,
|
||||
* and the header label all come from one apply, and each defers until the slot
|
||||
* it fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* that are already showing, so a default set from one converges the other.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROSTER_ONE = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** The roster after this browser copied one preset of its own. */
|
||||
const ROSTER_AUTHORED = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** The same roster with a second preset carrying the default. */
|
||||
const ROSTER_MOVED = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
// The host's answer, mutable so a spec can move the default the way the
|
||||
// settings surface does and watch who re-reads it.
|
||||
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
|
||||
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
const calls: string[] = []
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
agentPresets: {
|
||||
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
|
||||
read: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
|
||||
}),
|
||||
copy: (payload: { from: string; agentPreset: string }) => {
|
||||
calls.push(`copy:${payload.agentPreset}`)
|
||||
// The host's roster now contains it, which is the whole point of the
|
||||
// copy and what every surface must converge on.
|
||||
ROSTER = ROSTER_AUTHORED
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
openDocument: (payload: { agentPreset: string }) => {
|
||||
calls.push(`openDocument:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
|
||||
},
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
calls.push(`select:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
// The row reads this to learn whether this browser may write at all.
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
|
||||
}),
|
||||
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, calls, moveDefault }
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'settings.general.item': { kind: 'list', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
conversation: { kind: 'single', scope: 'root' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** The conversation's own declarations, which the chip and label wait for. */
|
||||
function declareConversation(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'conversation',
|
||||
children: {
|
||||
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** A workspaces double recording new-session starts. */
|
||||
function workspacesDouble() {
|
||||
const starts: unknown[] = []
|
||||
return {
|
||||
starts,
|
||||
startSession: (workspaceId?: unknown) => { starts.push(workspaceId ?? null) },
|
||||
}
|
||||
}
|
||||
|
||||
/** A sessions double whose list can be moved and whose changes are pushed. */
|
||||
function sessionsDouble(state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
}) {
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => state,
|
||||
subscribe: (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
},
|
||||
},
|
||||
/** Push a list change the way the runtime's store does. */
|
||||
notify: () => { for (const fn of listeners) fn() },
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const row = slots.entries('settings.general.item')[0]!
|
||||
expect(row.component).toBe(AgentPresetRow)
|
||||
expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 })
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.component).toBe(AgentPresetSection)
|
||||
expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('Agent 预设')
|
||||
})
|
||||
|
||||
it('registers into a declaration that arrives after apply', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
declareRoot(slots)
|
||||
|
||||
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('hands each surface its own store and actions', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
|
||||
expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection)
|
||||
// Each thunk reaches its own controller: the row's load fills the row's
|
||||
// store, and the section's default write does not go through the row.
|
||||
await row.load()
|
||||
await row.select('standard')
|
||||
await section.makeDefault('standard')
|
||||
expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows)
|
||||
.toEqual([{ id: 'standard', trust: 'system', isDefault: true }])
|
||||
})
|
||||
|
||||
it('routes the section actions to one controller', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
|
||||
await section.load()
|
||||
section.beginCopy('standard')
|
||||
section.cancelCopy()
|
||||
section.beginCopy('standard')
|
||||
section.setCopyId('mine')
|
||||
section.setCopyName('我的模式')
|
||||
await section.confirmCopy()
|
||||
await section.view('standard')
|
||||
section.closeView()
|
||||
section.confirmDelete('mine')
|
||||
await Promise.all([section.openLocation('mine'), section.remove()])
|
||||
|
||||
// One controller behind every action: the copy the dialog named is the
|
||||
// one the roster re-read reflects, and the delete the section confirmed
|
||||
// is the one its remove() sees.
|
||||
expect(calls).toContain('copy:mine')
|
||||
expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0)
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
const afterRelevant = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
await Promise.resolve()
|
||||
|
||||
// Both surfaces re-read on their own namespace; an unrelated one moves
|
||||
// neither, so this rules out a blanket refresh on every settings write.
|
||||
expect(calls.length).toBe(afterRelevant)
|
||||
})
|
||||
|
||||
it('re-reads both surfaces when the connection comes back', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('connection/reset')
|
||||
|
||||
// A reconnect can land on a host whose roster changed under the browser.
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
})
|
||||
|
||||
it('leaves the section alone until it has been opened once', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
|
||||
|
||||
// Only the General row reloads: a section nobody opened has nothing to
|
||||
// converge, and reading the roster for it would be a wasted round trip.
|
||||
expect(calls.length - before).toBe(1)
|
||||
})
|
||||
|
||||
it('registers the new-session chip and the header label, and drops both on disposal', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply })
|
||||
await fiber.await()
|
||||
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
expect(chip.component).toBe(AgentPresetSeat)
|
||||
const label = slots.entries('conversation.session.header.actions')[0]!
|
||||
expect(label.component).toBe(AgentPresetLabel)
|
||||
expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 })
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.session.header.actions')).toHaveLength(0)
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('moves the chip when the default changes on the settings surface', async () => {
|
||||
const { ctx, slots, moveDefault } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
await seat.load()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
|
||||
// The chip opens on the deployment default, and the setting it comes from
|
||||
// lives on another screen: without this the next session — the very one
|
||||
// the setting governs — would be composed from the previous default until
|
||||
// a reload.
|
||||
// An unrelated namespace moves nothing: the chip re-reads on its own
|
||||
// setting, not on every settings write in the process.
|
||||
moveDefault()
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
await Promise.resolve()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => {
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('offers a just-authored preset on the new-session chip', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
await seat.load()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
|
||||
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
section.beginCopy('standard')
|
||||
section.setCopyId('mine')
|
||||
section.setCopyName('我的模式')
|
||||
await section.confirmCopy()
|
||||
|
||||
// Authoring copies a directory rather than writing a setting, so nothing
|
||||
// on the wire announces it: a preset created to be used must appear on
|
||||
// the one screen that starts sessions, without a reload.
|
||||
await vi.waitFor(() => {
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
|
||||
})
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('applies the staged choice to the blank session the flow lands on', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
// Picked on the hero screen, where there is no session yet.
|
||||
await chip.select('minimal')
|
||||
expect(calls).not.toContain('select:minimal')
|
||||
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
sessions.notify()
|
||||
|
||||
// Connecting a workspace produced the session; the stage reaches it there.
|
||||
await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
|
||||
})
|
||||
|
||||
it('applies the stage to a session that records no preset of its own', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const sessions = sessionsDouble({
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true } },
|
||||
})
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
await chip.select('minimal')
|
||||
|
||||
// A session created before the deployment composed presets records none;
|
||||
// reading that as "already runs it" would drop the pick on the floor.
|
||||
expect(calls).toContain('select:minimal')
|
||||
})
|
||||
|
||||
it('forgets the stage once it has been spent', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state = {
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
||||
}
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
await chip.select('minimal')
|
||||
const spent = calls.filter(call => call === 'select:minimal').length
|
||||
sessions.notify()
|
||||
sessions.notify()
|
||||
|
||||
// Every later list movement would re-apply a stage that was not cleared,
|
||||
// switching sessions the user never picked for.
|
||||
await Promise.resolve()
|
||||
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent)
|
||||
})
|
||||
|
||||
it('gives the header label the same roster the General row reads', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const label = (slots.entries('conversation.session.header.actions')[0]!
|
||||
.inject as unknown as () => AgentPresetLabelInjected)()
|
||||
const row = (slots.entries('settings.general.item')[0]!
|
||||
.inject as unknown as () => AgentPresetRowInjected)()
|
||||
|
||||
await label.load()
|
||||
|
||||
// One roster behind both: the label resolves a name the settings row's own
|
||||
// load already fetched, rather than issuing a second read per session.
|
||||
expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset)
|
||||
expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
})
|
||||
|
||||
it('stages the creator preset and starts a session from the section', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
const workspaces = workspacesDouble()
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
section.startCreatorDraft?.()
|
||||
|
||||
// The pick is staged on the chip's own controller — the session the
|
||||
// workspace start produces is what the stage lands on — and exactly one
|
||||
// new-session flow began.
|
||||
expect(section.startCreatorDraft).toBeDefined()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
expect(workspaces.starts).toHaveLength(1)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('keeps the applied composition when the roster load lands late', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
section.startCreatorDraft?.()
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true }
|
||||
sessions.notify()
|
||||
await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
|
||||
|
||||
// The chip mounts with the flow's session, so its roster load can land
|
||||
// AFTER the stage was consumed; the session's own composition is what
|
||||
// the display must keep — not the deployment default.
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' }
|
||||
await seat.load()
|
||||
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('offers no creator draft while the conversation flow is absent', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
// No conversation scope mounted: the face omits the affordance and the
|
||||
// section hides its button rather than staging into nowhere.
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
expect(section.startCreatorDraft).toBeUndefined()
|
||||
})
|
||||
})
|
||||
300
packages/client/ui-agent-preset/tests/components.spec.tsx
Normal file
300
packages/client/ui-agent-preset/tests/components.spec.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The three conversation-adjacent surfaces: the General-settings row naming the
|
||||
* default for later sessions, the new-session chip naming the next one's, and
|
||||
* the session header's read-only label. The split is the host's rule — a
|
||||
* session's history is produced under its preset's tools, so the choice is
|
||||
* only ever offered before one starts.
|
||||
*/
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSettingsState } from '../src/client/settings-store.ts'
|
||||
import type { AgentPresetSeatState } from '../src/client/seat-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROW_READY: AgentPresetSettingsState = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
writable: true,
|
||||
currentValue: 'standard',
|
||||
// `mine` deliberately names itself nothing: the row must fall back to the
|
||||
// id for a preset whose author wrote no metadata.
|
||||
options: [{ id: 'standard', trust: 'system', name: '标准模式' }, { id: 'mine', trust: 'user' }],
|
||||
}
|
||||
|
||||
const SEAT_READY: AgentPresetSeatState = {
|
||||
current: 'standard',
|
||||
options: [
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
],
|
||||
busy: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetRow {...({
|
||||
...actions,
|
||||
useAgentPreset: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
roster: Partial<AgentPresetSettingsState> = {},
|
||||
) {
|
||||
// The chip and the label read the same roster, metadata included.
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>({
|
||||
...ROW_READY, options: SEAT_READY.options, ...roster,
|
||||
})
|
||||
const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } })
|
||||
const load = vi.fn(() => Promise.resolve())
|
||||
const view = render(<AgentPresetLabel {...({
|
||||
load,
|
||||
sessionId: 's1',
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useAgentPresets: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetLabelProps)} />)
|
||||
return { load, view }
|
||||
}
|
||||
|
||||
describe('the General-settings row', () => {
|
||||
it('reads the roster once and shows the current default', async () => {
|
||||
const actions = renderRow()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
})
|
||||
|
||||
it('marks a locally authored option as local', () => {
|
||||
renderRow()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// A local preset is exactly as privileged as the plugins it names, so the
|
||||
// list says which rows are local rather than presenting all as vetted.
|
||||
expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy()
|
||||
// The shipped one carries no marker; only local rows are called out.
|
||||
expect(screen.getAllByText('标准模式')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('falls back to the id for a preset that published no name', () => {
|
||||
renderRow({
|
||||
currentValue: 'mine',
|
||||
options: [
|
||||
{ id: 'standard', trust: 'system', name: '标准模式' },
|
||||
{ id: 'bare', trust: 'system' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
{ id: 'ours', trust: 'user', name: '团队模式' },
|
||||
],
|
||||
})
|
||||
|
||||
// The trigger names the preset; with no metadata the id is all there is.
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// A locally authored preset is marked whether or not it named itself.
|
||||
expect(screen.getByText(`团队模式 · ${en.userTrust}`)).toBeTruthy()
|
||||
expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy()
|
||||
// A shipped preset with no metadata is listed by id and carries no mark.
|
||||
expect(screen.getByText('bare')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('writes the picked preset and closes the menu', () => {
|
||||
const actions = renderRow()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('says it is loading before the roster answers', () => {
|
||||
renderRow({ status: 'loading', currentValue: '' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain(en.loading)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failure in place of the description', () => {
|
||||
renderRow({ error: 'roster unavailable' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('roster unavailable')
|
||||
})
|
||||
|
||||
it('renders nothing when the deployment composes no presets', () => {
|
||||
const { container } = render(<AgentPresetRow {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
useAgentPreset: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, status: 'unavailable', options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('closes and locks the menu when the settings turn read-only', () => {
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>(ROW_READY)
|
||||
render(<AgentPresetRow {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
useAgentPreset: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...ROW_READY, writable: false }) })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the new-session chip', () => {
|
||||
it('reads the roster once and shows the staged preset by name', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('offers each preset with what it is for', () => {
|
||||
renderSeat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The id alone never said what a preset does; the description is the
|
||||
// whole reason a preset can publish metadata at all.
|
||||
expect(screen.getByText('完整的编码 agent。')).toBeTruthy()
|
||||
// A preset that published none still reads as a row, with its id standing
|
||||
// in for the name.
|
||||
expect(screen.getByText(en.noDescription)).toBeTruthy()
|
||||
expect(screen.getByText('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the id when the staged preset published no name', () => {
|
||||
renderSeat({ current: 'mine' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
})
|
||||
|
||||
it('stages the picked preset and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText('mine'))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('disables the trigger while a switch is in flight', () => {
|
||||
renderSeat({ busy: true })
|
||||
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a refused switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
const empty = renderSeat({ options: [] })
|
||||
expect(empty).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
cleanup()
|
||||
|
||||
renderSeat({ current: '' })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
|
||||
// A control here would promise a switch the host refuses outright.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式')
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
renderLabel({ blank: true, agentPreset: 'mine' })
|
||||
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('mine')
|
||||
})
|
||||
|
||||
it('shows the id until the roster resolves it', () => {
|
||||
renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] })
|
||||
|
||||
// The session's own summary is the authority on which preset it runs; the
|
||||
// roster only supplies the display name, and its arrival is a later frame.
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('renders nothing, and reads no roster, when the session records no preset', async () => {
|
||||
const absent = renderLabel({ blank: true })
|
||||
expect(absent.view.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// A session the list has not caught up to is the same answer: a deployment
|
||||
// that composes no presets must not pay for a roster read per header.
|
||||
const unknown = renderLabel(undefined)
|
||||
expect(unknown.view.container.firstChild).toBeNull()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(absent.load).not.toHaveBeenCalled()
|
||||
expect(unknown.load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
25
packages/client/ui-agent-preset/tests/invariant.spec.ts
Normal file
25
packages/client/ui-agent-preset/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** The package's node half: an empty host body and an explained empty invariant companion. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('has an empty node half', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset')
|
||||
|
||||
// The host body exists only so the plugin appears in the host cordis.yml;
|
||||
// every surface this package ships lives in the browser half.
|
||||
apply()
|
||||
|
||||
expect(typeof apply).toBe('function')
|
||||
})
|
||||
})
|
||||
580
packages/client/ui-agent-preset/tests/section-store.spec.ts
Normal file
580
packages/client/ui-agent-preset/tests/section-store.spec.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* The agent-preset management controller: a copy dialog is the only way a
|
||||
* preset is created, the shipped compositions open in a read-only viewer, and
|
||||
* the way into a custom preset's files is the location action — opened on a
|
||||
* desktop, revealed as a path where the host has none. Every mutation
|
||||
* re-reads the roster because a copy changes more than the row it targeted.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
|
||||
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
|
||||
|
||||
interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
|
||||
interface Recorded { method: string; payload: unknown }
|
||||
|
||||
interface FakeOptions {
|
||||
/** Every call the controller made, in order. */
|
||||
calls?: Recorded[]
|
||||
/** Reject `list` with this message. */
|
||||
failList?: string
|
||||
/** Reject `read` with this message. */
|
||||
failRead?: string
|
||||
/** Reject `copy` with this message. */
|
||||
failCopy?: string
|
||||
/** Reject `openDocument` with this message. */
|
||||
failOpen?: string
|
||||
/** Reject `remove` with this message. */
|
||||
failRemove?: string
|
||||
/** Reject `settings.update` with this message. */
|
||||
failSettings?: string
|
||||
/** Throw from `list` rather than answering, as a dead transport does. */
|
||||
throwList?: boolean
|
||||
/** Throw from `read`, as a dead transport does. */
|
||||
throwRead?: boolean
|
||||
/** Throw from `copy`, as a dead transport does. */
|
||||
throwCopy?: boolean
|
||||
/** Throw from `openDocument`, as a dead transport does. */
|
||||
throwOpen?: boolean
|
||||
/** Whether the deployment configures a writable root. */
|
||||
authorable?: boolean
|
||||
/** Whether the host can open a preset directory on a desktop. */
|
||||
hasDocument?: boolean
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
}
|
||||
|
||||
const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
|
||||
const fail = (message: string) =>
|
||||
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
|
||||
|
||||
/**
|
||||
* A wire face over an in-memory preset store: copies land, so the roster the
|
||||
* controller re-reads after a copy is the one the copy produced.
|
||||
* @param presets - the starting compositions by id.
|
||||
* @param defaultId - the preset a session with no choice gets.
|
||||
* @param options - failure injection and call recording.
|
||||
* @returns the fake client.
|
||||
*/
|
||||
function fakeApi(
|
||||
presets: Map<string, FakePreset>,
|
||||
defaultId: { id: string },
|
||||
options: FakeOptions = {},
|
||||
): Pick<IApiClient, 'agentPresets' | 'settings'> {
|
||||
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
record('list', {})
|
||||
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failList !== undefined) return fail(options.failList)
|
||||
return ok({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
hasDocument: options.hasDocument ?? true,
|
||||
})
|
||||
},
|
||||
read: (payload: { agentPreset: string }) => {
|
||||
record('read', payload)
|
||||
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failRead !== undefined) return fail(options.failRead)
|
||||
const preset = presets.get(payload.agentPreset)
|
||||
/* v8 ignore next -- every test reads an id the fake store holds */
|
||||
if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
|
||||
return ok({
|
||||
agentPreset: payload.agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})
|
||||
},
|
||||
copy: (payload: { from: string; agentPreset: string; name?: string }) => {
|
||||
record('copy', payload)
|
||||
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failCopy !== undefined) return fail(options.failCopy)
|
||||
const source = presets.get(payload.from)
|
||||
/* v8 ignore next -- every test copies a source the fake store holds */
|
||||
if (source === undefined) return fail(`unknown preset ${payload.from}`)
|
||||
presets.set(payload.agentPreset, {
|
||||
trust: 'user',
|
||||
content: source.content,
|
||||
...payload.name === undefined ? {} : { name: payload.name },
|
||||
})
|
||||
return ok({ agentPreset: payload.agentPreset })
|
||||
},
|
||||
openDocument: (payload: { agentPreset: string }) => {
|
||||
record('openDocument', payload)
|
||||
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failOpen !== undefined) return fail(options.failOpen)
|
||||
return (options.hasDocument ?? true)
|
||||
? ok({ opened: true })
|
||||
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
|
||||
},
|
||||
remove: async (payload: { agentPreset: string }) => {
|
||||
record('remove', payload)
|
||||
await options.holdRemove
|
||||
if (options.failRemove !== undefined) return await fail(options.failRemove)
|
||||
presets.delete(payload.agentPreset)
|
||||
return await ok({})
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: { default?: string } }) => {
|
||||
record('settings.update', payload)
|
||||
if (options.failSettings !== undefined) return fail(options.failSettings)
|
||||
/* v8 ignore next -- the controller only ever patches `default` */
|
||||
defaultId.id = payload.patch.default ?? defaultId.id
|
||||
return ok({})
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>
|
||||
}
|
||||
|
||||
function seed(): Map<string, FakePreset> {
|
||||
return new Map<string, FakePreset>([
|
||||
['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
|
||||
['mine', { trust: 'user', content: '- id: tool-read\n' }],
|
||||
])
|
||||
}
|
||||
|
||||
function harness(options: FakeOptions = {}) {
|
||||
const presets = seed()
|
||||
const defaultId = { id: 'standard' }
|
||||
const calls: Recorded[] = []
|
||||
let rosterChanges = 0
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
|
||||
() => { rosterChanges += 1 },
|
||||
)
|
||||
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
|
||||
}
|
||||
|
||||
function copyOf(controller: AgentPresetSectionController): CopyDraft {
|
||||
const { copy } = controller.store.getSnapshot()
|
||||
if (copy === null) throw new Error('expected an open copy dialog')
|
||||
return copy
|
||||
}
|
||||
|
||||
describe('loading the roster', () => {
|
||||
it('maps the roster onto rows with the capability flags', async () => {
|
||||
const { controller } = harness({ authorable: true, hasDocument: false })
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.authorable).toBe(true)
|
||||
expect(state.hasDocument).toBe(false)
|
||||
expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
|
||||
expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
|
||||
})
|
||||
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const { controller, presets } = harness()
|
||||
presets.clear()
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('keeps one load in flight rather than stacking reads', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a refusal as the page error', async () => {
|
||||
const { controller } = harness({ failList: 'not for you' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.error).toBe('not for you')
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller } = harness({ throwList: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
expect(controller.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the read-only viewer', () => {
|
||||
it('opens a shipped composition under its display name', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.view('standard')
|
||||
|
||||
expect(controller.store.getSnapshot().view).toEqual({
|
||||
id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the id when the preset published no name', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.view('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().view?.title).toBe('mine')
|
||||
})
|
||||
|
||||
it('closes without touching the list', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
await controller.view('standard')
|
||||
|
||||
controller.closeView()
|
||||
|
||||
expect(controller.store.getSnapshot().view).toBeNull()
|
||||
expect(controller.store.getSnapshot().rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('puts a read refusal on the page rather than opening empty', async () => {
|
||||
const { controller } = harness({ failRead: 'no peeking' })
|
||||
await controller.load()
|
||||
|
||||
await controller.view('standard')
|
||||
|
||||
expect(controller.store.getSnapshot().view).toBeNull()
|
||||
expect(controller.store.getSnapshot().error).toBe('no peeking')
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller } = harness({ throwRead: true })
|
||||
await controller.load()
|
||||
|
||||
await controller.view('standard')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the copy dialog', () => {
|
||||
it('opens over the source with its display name in the title', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.beginCopy('standard')
|
||||
|
||||
expect(copyOf(controller)).toMatchObject({
|
||||
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the source id when it published no name', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.beginCopy('mine')
|
||||
|
||||
expect(copyOf(controller).fromTitle).toBe('mine')
|
||||
})
|
||||
|
||||
it('cancel discards whatever was typed', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('half-typed')
|
||||
|
||||
controller.cancelCopy()
|
||||
|
||||
expect(controller.store.getSnapshot().copy).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores field edits and submits with no dialog open', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.setCopyId('typed-into-nothing')
|
||||
controller.setCopyName('nameless')
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(controller.store.getSnapshot().copy).toBeNull()
|
||||
expect(calls.some(call => call.method === 'copy')).toBe(false)
|
||||
})
|
||||
|
||||
it('typing clears the previous failure', async () => {
|
||||
const { controller } = harness({ failCopy: 'disk full' })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
await controller.confirmCopy()
|
||||
expect(copyOf(controller).error).toBe('disk full')
|
||||
|
||||
controller.setCopyName('renamed')
|
||||
|
||||
expect(copyOf(controller).error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the copy blocker', () => {
|
||||
const rows: PresetRow[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]
|
||||
const draft = (id: string): CopyDraft =>
|
||||
({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
|
||||
|
||||
it('requires an id, a containable shape, and a free name', () => {
|
||||
expect(draftBlocker(draft(''), rows)).toBe('idRequired')
|
||||
expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
|
||||
expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
|
||||
expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
|
||||
expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitting a copy', () => {
|
||||
it('copies, re-reads the roster, announces the change, and opens the files', async () => {
|
||||
const { controller, calls, rosterChanges } = harness()
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
controller.setCopyName('我的模式')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.copy).toBeNull()
|
||||
expect(state.rows.map(row => row.id)).toContain('my-copy')
|
||||
expect(rosterChanges()).toBe(1)
|
||||
expect(calls.find(call => call.method === 'copy')?.payload)
|
||||
.toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
|
||||
// A preset is its files from here on, so landing in them completes the
|
||||
// copy rather than following it.
|
||||
expect(calls.find(call => call.method === 'openDocument')?.payload)
|
||||
.toEqual({ agentPreset: 'my-copy' })
|
||||
})
|
||||
|
||||
it('omits an empty name so the copy falls back to its id', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
controller.setCopyName(' ')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(calls.find(call => call.method === 'copy')?.payload)
|
||||
.toEqual({ from: 'standard', agentPreset: 'my-copy' })
|
||||
})
|
||||
|
||||
it('reveals the new directory as text where the host has no desktop', async () => {
|
||||
const { controller } = harness({ hasDocument: false })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
|
||||
})
|
||||
|
||||
it('keeps the dialog open with the refusal on it', async () => {
|
||||
const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
|
||||
expect(rosterChanges()).toBe(0)
|
||||
})
|
||||
|
||||
it('folds a dead transport into the dialog error', async () => {
|
||||
const { controller } = harness({ throwCopy: true })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(copyOf(controller).error).toContain('socket closed')
|
||||
})
|
||||
|
||||
it('refuses to submit while blocked or already saving', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('mine')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(calls.some(call => call.method === 'copy')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the location action', () => {
|
||||
it('opens the directory and leaves the page alone on a desktop host', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' })
|
||||
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
|
||||
})
|
||||
|
||||
it('reveals the path on the row where the host has none', async () => {
|
||||
const { controller } = harness({ hasDocument: false })
|
||||
await controller.load()
|
||||
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
|
||||
})
|
||||
|
||||
it('drops a revealed path once its preset leaves the roster', async () => {
|
||||
const { controller, presets } = harness({ hasDocument: false })
|
||||
await controller.load()
|
||||
await controller.openLocation('mine')
|
||||
presets.delete('mine')
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
|
||||
})
|
||||
|
||||
it('surfaces a refusal as the page error', async () => {
|
||||
const { controller } = harness({ failOpen: 'not yours' })
|
||||
await controller.load()
|
||||
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('not yours')
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller } = harness({ throwOpen: true })
|
||||
await controller.load()
|
||||
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting', () => {
|
||||
it('asks first, then deletes, re-reads, and announces the change', async () => {
|
||||
const { controller, rosterChanges } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
|
||||
await controller.remove()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.pendingDelete).toBeNull()
|
||||
expect(state.rows.map(row => row.id)).not.toContain('mine')
|
||||
expect(rosterChanges()).toBe(1)
|
||||
})
|
||||
|
||||
it('dismisses the confirmation without deleting', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
|
||||
controller.confirmDelete(null)
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
|
||||
expect(calls.some(call => call.method === 'remove')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a second confirmation while one delete is in flight', async () => {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const { controller, calls } = harness({ holdRemove: gate })
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
const removal = controller.remove()
|
||||
|
||||
controller.confirmDelete('standard')
|
||||
await controller.remove()
|
||||
release()
|
||||
await removal
|
||||
|
||||
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a refusal and clears the confirmation', async () => {
|
||||
const { controller } = harness({ failRemove: 'shipped preset' })
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
|
||||
await controller.remove()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.error).toBe('shipped preset')
|
||||
expect(state.pendingDelete).toBeNull()
|
||||
expect(state.deleting).toBe(false)
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
presets.clear()
|
||||
const broken = new AgentPresetSectionController({
|
||||
agentPresets: {
|
||||
list: () => Promise.reject(new Error('gone')),
|
||||
remove: () => Promise.reject(new Error('socket closed')),
|
||||
},
|
||||
settings: {},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
|
||||
broken.confirmDelete('mine')
|
||||
|
||||
await broken.remove()
|
||||
|
||||
expect(broken.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a controller with no roster listener', () => {
|
||||
it('completes a delete without anyone to notify', async () => {
|
||||
// The rosterChanged callback is optional wiring, not a requirement: a
|
||||
// page composed without sibling surfaces still deletes cleanly.
|
||||
const presets = seed()
|
||||
const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
|
||||
await alone.load()
|
||||
alone.confirmDelete('mine')
|
||||
|
||||
await alone.remove()
|
||||
|
||||
expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default preset', () => {
|
||||
it('writes the setting and re-reads the roster', async () => {
|
||||
const { controller, defaultId } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(defaultId.id).toBe('mine')
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces a settings refusal as the page error', async () => {
|
||||
const { controller } = harness({ failSettings: 'read-only settings' })
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toContain('read-only settings')
|
||||
})
|
||||
})
|
||||
434
packages/client/ui-agent-preset/tests/section.spec.tsx
Normal file
434
packages/client/ui-agent-preset/tests/section.spec.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The management section's rendering rules: which actions a row offers depends
|
||||
* on its trust, a shipped composition opens in a read-only viewer, creation is
|
||||
* a copy dialog that collects an id and an optional name, and the location
|
||||
* action follows the host's desktop capability.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const READY: AgentPresetSectionState = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
copy: null,
|
||||
view: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
revealedPaths: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the section over a fixed snapshot, with every action a spy.
|
||||
* @param state - the snapshot to render.
|
||||
* @returns the spies, so a test can assert what a click reached.
|
||||
*/
|
||||
function renderSection(
|
||||
state: Partial<AgentPresetSectionState> = {},
|
||||
options: { creator?: boolean } = {},
|
||||
) {
|
||||
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
// The shell-owned section affordance (SettingsSectionOwnerProps.close).
|
||||
close: vi.fn(),
|
||||
...options.creator === false ? {} : { startCreatorDraft: vi.fn() },
|
||||
view: vi.fn(() => Promise.resolve()),
|
||||
closeView: vi.fn(),
|
||||
beginCopy: vi.fn(),
|
||||
cancelCopy: vi.fn(),
|
||||
setCopyId: vi.fn(),
|
||||
setCopyName: vi.fn(),
|
||||
confirmCopy: vi.fn(() => Promise.resolve()),
|
||||
openLocation: vi.fn(() => Promise.resolve()),
|
||||
confirmDelete: vi.fn(),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
makeDefault: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
useAgentPresetSection: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSectionProps
|
||||
render(<AgentPresetSection {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
/** Locate a card by the id it prints, not by its display name. */
|
||||
function rowFor(id: string): HTMLElement {
|
||||
const key = screen.getAllByText(id).find(node => node.tagName === 'CODE')
|
||||
const row = key?.closest('li') ?? null
|
||||
/* v8 ignore next -- every rendered card prints its id */
|
||||
if (row === null) throw new Error(`no card for ${id}`)
|
||||
return row
|
||||
}
|
||||
|
||||
describe('the preset list', () => {
|
||||
it('reads the roster once when it first renders', async () => {
|
||||
const actions = renderSection()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('shows the published name and description, falling back to the id', () => {
|
||||
renderSection()
|
||||
|
||||
// The name is what a picker reads; the id stays visible as the key the
|
||||
// composition and the session header actually carry.
|
||||
expect(screen.getByText('标准模式')).toBeTruthy()
|
||||
expect(screen.getByText('完整的编码 agent。')).toBeTruthy()
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0)
|
||||
expect(within(mine).getByText(en.noDescription)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks trust and the one in use, and offers no "set default" on it', () => {
|
||||
renderSection()
|
||||
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByText(en.builtIn)).toBeTruthy()
|
||||
expect(within(standard).getByText(en.inUse)).toBeTruthy()
|
||||
expect(within(standard).queryByText(en.setDefault)).toBeNull()
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('separates built-in presets from custom ones', () => {
|
||||
renderSection()
|
||||
|
||||
// Two different things: one set ships with the deployment and is
|
||||
// read-only, the other is the user's own.
|
||||
expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no group heading for a set nobody has', () => {
|
||||
renderSection({ rows: [{ id: 'standard', trust: 'system', isDefault: true }] })
|
||||
|
||||
expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with the two ways a preset is created', () => {
|
||||
renderSection()
|
||||
|
||||
// The page has no create button: the intro is what tells a first-time
|
||||
// reader that copying an existing preset — or drafting one in Creator
|
||||
// mode — IS the way to make one.
|
||||
expect(screen.getByText(new RegExp('Creator mode'))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('picks a preset by clicking its card, and the one in use is inert', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: 标准模式` })
|
||||
expect(inUse).toHaveProperty('disabled', true)
|
||||
fireEvent.click(inUse)
|
||||
|
||||
// Clicking the card IS the choice; the preset already in use cannot be
|
||||
// re-picked, so the click reaches nothing.
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers View on a shipped row and the location on a custom one', () => {
|
||||
renderSection()
|
||||
|
||||
// A shipped preset is the composition a copy starts from — reading it is
|
||||
// the point. A custom preset is edited in its files, so its row leads
|
||||
// there instead; there is no editor for either.
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy()
|
||||
expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull()
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy()
|
||||
expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers Delete only for a locally authored preset', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy()
|
||||
expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull()
|
||||
})
|
||||
|
||||
it('disables duplication when nothing is writable, and says why', () => {
|
||||
renderSection({ authorable: false })
|
||||
|
||||
const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` })
|
||||
expect(duplicate).toHaveProperty('disabled', true)
|
||||
expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
|
||||
})
|
||||
|
||||
it('marks a broken custom preset: unselectable, uncopyable, still deletable', () => {
|
||||
const actions = renderSection({
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', broken: 'the composition file agent.cordis.yml is missing' },
|
||||
],
|
||||
})
|
||||
|
||||
const ghost = rowFor('ghost')
|
||||
// The reason is on the card, and the body cannot pick what cannot mount.
|
||||
expect(within(ghost).getByText(en.brokenBadge)).toBeTruthy()
|
||||
expect(within(ghost).getByRole('alert').textContent).toContain('is missing')
|
||||
const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` })
|
||||
expect(body).toHaveProperty('disabled', true)
|
||||
fireEvent.click(body)
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
// Copying a broken preset would only mint another broken one; deleting
|
||||
// and the location remain — the files are where it gets fixed.
|
||||
const duplicate = within(ghost).getByRole('button', { name: `${en.duplicate}: 幽灵预设` })
|
||||
expect(duplicate).toHaveProperty('disabled', true)
|
||||
expect(duplicate.getAttribute('data-tip')).toBe(en.brokenNoCopy)
|
||||
expect(within(ghost).getByRole('button', { name: `${en.delete}: 幽灵预设` })).toBeTruthy()
|
||||
expect(within(ghost).getByRole('button', { name: `${en.openLocation}: 幽灵预设` })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('withholds the viewer on a broken shipped preset', () => {
|
||||
renderSection({
|
||||
rows: [{ id: 'standard', trust: 'system', isDefault: false, name: '标准模式', broken: 'the composition is not valid YAML' }],
|
||||
})
|
||||
|
||||
// There is no readable composition to offer; the reason on the card is
|
||||
// the whole story a shipped row can tell.
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull()
|
||||
expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML')
|
||||
})
|
||||
|
||||
it('labels the location by what it will do without a desktop', () => {
|
||||
renderSection({ hasDocument: false })
|
||||
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a revealed directory on its row', () => {
|
||||
renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } })
|
||||
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy()
|
||||
expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy()
|
||||
// The reveal belongs to its row alone.
|
||||
expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull()
|
||||
})
|
||||
|
||||
it('routes the row actions to the controller', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
// The card body is the control that picks a preset.
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` }))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` }))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` }))
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` }))
|
||||
|
||||
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
|
||||
expect(actions.openLocation).toHaveBeenCalledWith('mine')
|
||||
expect(actions.beginCopy).toHaveBeenCalledWith('mine')
|
||||
expect(actions.view).toHaveBeenCalledWith('standard')
|
||||
})
|
||||
|
||||
it('starts a creator-mode draft session and leaves settings', () => {
|
||||
const actions = renderSection({
|
||||
rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.creatorDraft }))
|
||||
|
||||
expect(actions.startCreatorDraft).toHaveBeenCalledTimes(1)
|
||||
// Leaving settings is part of the gesture: the flow lands in the new
|
||||
// session, not behind the modal.
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
|
||||
renderSection()
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
cleanup()
|
||||
|
||||
renderSection({
|
||||
rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }],
|
||||
}, { creator: false })
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const actions = renderSection({
|
||||
authorable: false,
|
||||
rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }],
|
||||
})
|
||||
const disabled = screen.getByRole('button', { name: en.creatorDraft })
|
||||
expect(disabled).toHaveProperty('disabled', true)
|
||||
fireEvent.click(disabled)
|
||||
expect(actions.startCreatorDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a page-level failure without hiding the list', () => {
|
||||
renderSection({ error: 'settings are read-only' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('settings are read-only')
|
||||
expect(rowFor('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing when the deployment composes no presets', () => {
|
||||
const { container } = render(<AgentPresetSection {...({
|
||||
useAgentPresetSection: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as AgentPresetSectionProps)} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a retry when the roster could not be read', () => {
|
||||
const actions = renderSection({ status: 'error', error: 'roster unavailable' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain('roster unavailable')
|
||||
fireEvent.click(screen.getByText(en.retry))
|
||||
|
||||
expect(actions.load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the copy dialog', () => {
|
||||
const draft: CopyDraft = {
|
||||
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null,
|
||||
}
|
||||
|
||||
it('names its source and collects only an id and a display name', () => {
|
||||
const actions = renderSection({ copy: draft })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`)
|
||||
expect(within(dialog).getByText(en.copyIntro)).toBeTruthy()
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } })
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } })
|
||||
|
||||
expect(actions.setCopyId).toHaveBeenCalledWith('my-agent')
|
||||
expect(actions.setCopyName).toHaveBeenCalledWith('我的模式')
|
||||
// Nothing else is collected: the description and the composition are
|
||||
// edited in the preset's own files.
|
||||
expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('creates and cancels through the controller', () => {
|
||||
const actions = renderSection({ copy: { ...draft, id: 'my-agent' } })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByText(en.create))
|
||||
fireEvent.click(within(dialog).getByText(en.cancel))
|
||||
|
||||
expect(actions.confirmCopy).toHaveBeenCalledTimes(1)
|
||||
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('blocks a copy the host would refuse, and says why', () => {
|
||||
const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid)
|
||||
fireEvent.click(within(dialog).getByText(en.create))
|
||||
|
||||
// Disabled rather than round-tripping: the id is a directory name and the
|
||||
// rule is the host's own.
|
||||
expect(actions.confirmCopy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the host\'s refusal instead of the local blocker', () => {
|
||||
renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } })
|
||||
|
||||
expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists')
|
||||
})
|
||||
|
||||
it('reports a copy in flight and blocks a second click', () => {
|
||||
const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating))
|
||||
|
||||
expect(actions.confirmCopy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses on Escape', () => {
|
||||
const actions = renderSection({ copy: draft })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the read-only viewer', () => {
|
||||
it('shows the composition text under the preset\'s name', () => {
|
||||
renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`)
|
||||
expect(within(dialog).getByText(en.composition)).toBeTruthy()
|
||||
expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n')
|
||||
})
|
||||
|
||||
it('closes through the controller', () => {
|
||||
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close))
|
||||
|
||||
expect(actions.closeView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses on Escape', () => {
|
||||
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.closeView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('asks before deleting', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` }))
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
|
||||
})
|
||||
|
||||
it('confirms and dismisses through the controller', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine' })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByText(en.deleteConfirm))
|
||||
fireEvent.click(within(dialog).getByText(en.cancel))
|
||||
|
||||
expect(actions.remove).toHaveBeenCalledTimes(1)
|
||||
expect(actions.confirmDelete).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('dismisses the confirmation on Escape', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine' })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('reports a delete in flight', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine', deleting: true })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting))
|
||||
|
||||
expect(actions.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
458
packages/client/ui-agent-preset/tests/settings-store.spec.ts
Normal file
458
packages/client/ui-agent-preset/tests/settings-store.spec.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* The agent-preset settings controller: it derives both the options and the
|
||||
* current default from one roster call, writes only the `default` field, and
|
||||
* treats an empty roster as "this deployment composes no presets" rather than
|
||||
* as a failure.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
import type { SeatSessionSummary } from '../src/client/seat-store.ts'
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
/** A client whose roster and write outcome the test controls. */
|
||||
function fakeApi(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: {
|
||||
writes?: Recorded[]
|
||||
failWrite?: string
|
||||
failList?: string
|
||||
failWriteWith?: Error
|
||||
readOnly?: boolean
|
||||
} = {},
|
||||
): IApiClient {
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
|
||||
},
|
||||
settings: {
|
||||
// Loopback-only in production; a read-only provider answers writable:false
|
||||
// and the row disables its control instead of offering a refused write.
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
|
||||
},
|
||||
}),
|
||||
update: (payload: { ns: string; patch: unknown }) => {
|
||||
options.writes?.push({ ns: payload.ns, patch: payload.patch })
|
||||
if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
|
||||
if (options.failWrite !== undefined) {
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
|
||||
}
|
||||
// A committed write moves the roster's default, exactly as the host does.
|
||||
for (const preset of presets) {
|
||||
preset.isDefault = preset.id === (payload.patch as { default?: string }).default
|
||||
}
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } })
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
}
|
||||
|
||||
describe('the agent-preset settings controller', () => {
|
||||
it('disables the control when this browser may not write settings', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
], { readOnly: true }))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// `settings.describe` is loopback-only and reports a read-only provider;
|
||||
// offering a control whose write answers `settings-not-exposed` would
|
||||
// promise a switch the host refuses.
|
||||
expect(controller.store.getSnapshot().writable).toBe(false)
|
||||
expect(controller.store.getSnapshot().currentValue).toBe('standard')
|
||||
})
|
||||
|
||||
it('derives options and the current default from one roster call', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.currentValue).toBe('standard')
|
||||
expect(state.options).toEqual([
|
||||
{ id: 'standard', trust: 'system' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
])
|
||||
})
|
||||
|
||||
it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' },
|
||||
] as never))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A broken preset cannot compose a session; listing it here would defer
|
||||
// that discovery to a failed session start. The management section shows
|
||||
// (and deletes) it from its own store instead.
|
||||
expect(controller.store.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
|
||||
})
|
||||
|
||||
it('carries the display metadata a preset published', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Surfaces beyond this row read the same options; the id alone never said
|
||||
// what a preset does.
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A deployment composing no presets is valid: every session shares the
|
||||
// host composition and the row renders nothing.
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(controller.store.getSnapshot().error).toBeNull()
|
||||
})
|
||||
|
||||
it('writes only the default field, into the agent-presets namespace', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
], { writes }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'minimal' } }])
|
||||
expect(controller.store.getSnapshot().currentValue).toBe('minimal')
|
||||
})
|
||||
|
||||
it('restores the previous value and surfaces the message when the write fails', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
], { failWrite: 'read-only settings' }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.currentValue).toBe('standard')
|
||||
expect(state.error).toBe('read-only settings')
|
||||
expect(state.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('ignores a pick that is already the default', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
], { writes }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('standard')
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a roster failure without claiming the deployment has no presets', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' }))
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.error).toBe('host down')
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
// Settings can name a preset that was since deleted; the picker still has
|
||||
// to show something rather than an empty control.
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().currentValue).toBe('standard')
|
||||
})
|
||||
|
||||
it('ignores a load while one is already in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi(
|
||||
[{ id: 'standard', trust: 'system', isDefault: true }], { writes }))
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('reads an Error\'s message and stringifies anything else', () => {
|
||||
// A transport rejects with an Error, but a host or a runtime can reject
|
||||
// with anything and the surface still has to say something.
|
||||
expect(messageOf(new Error('boom'))).toBe('boom')
|
||||
expect(messageOf({ code: 7 })).toBe('[object Object]')
|
||||
})
|
||||
|
||||
it('reports a transport that rejects rather than answering', async () => {
|
||||
const controller = new AgentPresetSettingsController({
|
||||
agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-write and keeps the old default showing', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
], { failWriteWith: new Error('socket closed') }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('mine')
|
||||
|
||||
// The value snaps back because the host never took it; a picker still
|
||||
// showing "mine" would be claiming a default that does not exist.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the new-session chip controller', () => {
|
||||
/** A chip over a current session the test can move. */
|
||||
function chip(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
current: { id: string; blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined)
|
||||
}
|
||||
|
||||
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
]
|
||||
|
||||
it('opens on the deployment default', async () => {
|
||||
const controller = chip(ROSTER, undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The chip names the session about to start, and nothing about it is
|
||||
// decided yet — the default is the honest opening value.
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system' },
|
||||
{ id: 'minimal', trust: 'system' },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Settings can name a preset that was since deleted; the chip still has
|
||||
// to open on something rather than render nothing.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('carries the display metadata into the menu rows', async () => {
|
||||
const controller = chip([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never, undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
])
|
||||
})
|
||||
|
||||
it('opens on nothing when the deployment composes no presets', async () => {
|
||||
const controller = chip([], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// An empty roster is a valid deployment: every session shares the host
|
||||
// composition, and the chip renders nothing rather than an empty control.
|
||||
expect(controller.store.getSnapshot().current).toBe('')
|
||||
})
|
||||
|
||||
it('stages a pick made before any session exists', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, undefined, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Nothing to switch yet: the new-session screen precedes the session.
|
||||
expect(writes).toEqual([])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('applies the stage to the blank session the flow lands on', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const current = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
const controller = chip(ROSTER, current, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('spends the stage exactly once', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
await controller.apply()
|
||||
await controller.apply()
|
||||
|
||||
// Every later list movement calls apply(); an unspent stage would keep
|
||||
// switching sessions the user never picked for.
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
})
|
||||
|
||||
it('drops the stage against a session that already started', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// The host enforces the same rule; the chip simply never asks.
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the stage when the session already runs it', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the default when the host refuses the switch', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Showing `minimal` after a refusal would claim a composition the session
|
||||
// never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' })
|
||||
})
|
||||
|
||||
it('falls back to the default when the switch never reaches the host', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(controller.store.getSnapshot())
|
||||
.toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a pick while a switch is in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
const first = controller.select('minimal')
|
||||
await controller.select('standard')
|
||||
await first
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
})
|
||||
|
||||
it('keeps a staged pick across a roster refresh', async () => {
|
||||
const controller = chip(ROSTER, undefined)
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A settings push re-reads the roster; it must not silently discard what
|
||||
// the user picked for the session they are about to start.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the chip', async () => {
|
||||
const controller = chip(ROSTER, undefined, { failList: 'host down' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects the roster read', async () => {
|
||||
const controller = chip(ROSTER, undefined, { throwOn: 'list' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
it('reports a refused describe as a failure rather than a half-read row', async () => {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||
}),
|
||||
},
|
||||
// The roster answered; `settings.describe` is what rejected, and the row
|
||||
// cannot claim a writable default it never confirmed.
|
||||
settings: { describe: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient
|
||||
const controller = new AgentPresetSettingsController(api)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
45
packages/client/ui-agent-preset/tsconfig.json
Normal file
45
packages/client/ui-agent-preset/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../test-runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-agent-preset/tsdown.config.ts
Normal file
3
packages/client/ui-agent-preset/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -192,6 +192,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.left': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.right': { kind: 'list', scope: 'session' },
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
|
||||
|
||||
@@ -79,6 +79,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* reads the global workspace list.
|
||||
*/
|
||||
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
|
||||
/**
|
||||
* The agent-preset chip beside the workspace picker on the new-session
|
||||
* screen. Root scope: no session exists yet, so the choice is staged for
|
||||
* the next one rather than applied to a current one.
|
||||
*/
|
||||
'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
|
||||
// 'conversation.input.overlay' merges in ui-slash (the dependency
|
||||
// direction is the hard constraint — ui-slash cannot import
|
||||
// this package, while this package's input contract already imports
|
||||
@@ -141,6 +147,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of the hero agent-preset chip: the shell supplies nothing. */
|
||||
export interface HeroAgentPresetOwnerProps {
|
||||
/** Marker field: the chip owns its own roster, staging, and menu state. */
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of the strict session content seat. */
|
||||
export interface ConversationSessionOwnerProps {
|
||||
/**
|
||||
* Wrap the view ring in the transcript scrollport that also hosts the
|
||||
* sticky composer seat (whole `'conversation.composer'` chain output).
|
||||
* Supplied for every real session (hero/settling/active) so the composer
|
||||
* keeps one tree seat across the blank → active flip; the header stays
|
||||
* outside that wrapper as ordinary column chrome (`flex: none`), while
|
||||
* active CSS sticks the seat to the bottom of the same scrollport so wheel
|
||||
* over the footer scrolls the flow.
|
||||
* @param view - the session view-ring content (null while blank chrome is hidden).
|
||||
* @returns the scrollport containing `view` and the sticky composer seat.
|
||||
*/
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/** Header actions derive their state from the standard session/global kit. */
|
||||
export interface ConversationHeaderActionOwnerProps {}
|
||||
|
||||
@@ -430,6 +458,7 @@ export type ConversationSlotProps =
|
||||
| 'conversation.input.dock' | 'conversation.composer.dock'
|
||||
| 'conversation.input.left' | 'conversation.input.right'
|
||||
| 'conversation.hero.workspace'
|
||||
| 'conversation.hero.agentPreset'
|
||||
>
|
||||
& InjectFace<ConversationInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
@@ -119,6 +119,7 @@ export function ConversationRoot({
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
{renderSlot('conversation.hero.agentPreset', {})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -84,9 +84,11 @@ describe('apply wiring', () => {
|
||||
expect(conversationHeader?.store).toBe(conversationSession?.store)
|
||||
expect(details?.store).toBe(conversationSession?.store)
|
||||
expect(chatView?.store).toBe(conversationSession?.store)
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
// The hero holes ride the conversation entry's children declaration (the
|
||||
// empty-state occupant is gone). Both are root-scoped: the new-session
|
||||
// screen precedes the session either would belong to.
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -721,13 +721,15 @@ describe('strips and variants', () => {
|
||||
})
|
||||
|
||||
describe('command launcher chrome and control seats', () => {
|
||||
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries', () => {
|
||||
it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('命令')).toBeTruthy()
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
||||
// Both seats dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
||||
// Every seat dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual([
|
||||
'conversation.input.plan', 'conversation.input.model',
|
||||
])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -453,6 +453,9 @@ describe('ConversationRoot resident composer', () => {
|
||||
const chip = b.view.getByRole('button', { name: '选择工作区' })
|
||||
expect((chip as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(b.slotCalls).toContain('conversation.hero.workspace')
|
||||
// The agent-preset chip sits in the same row, for the same reason: both
|
||||
// choices are only open before the first message.
|
||||
expect(b.slotCalls).toContain('conversation.hero.agentPreset')
|
||||
})
|
||||
|
||||
it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
|
||||
|
||||
@@ -584,6 +584,17 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* folder_open_16, outline layer only: the duotone original above reads a rung
|
||||
* heavier than the …Outline16 family, so an icon-button row mixing them looks
|
||||
* mismatched — this is the same geometry without the 20%-opacity inner fill.
|
||||
*/
|
||||
export const IconFolderOpenOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
||||
export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
|
||||
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full icon set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(66)
|
||||
it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(67)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md
|
||||
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
|
||||
README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691
|
||||
README.md: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42
|
||||
README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
|
||||
Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets).
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
|
||||
Web 提问功能插件:其浏览器侧把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent,无论它由哪个 preset 组装,于是一个"两工具"的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
/**
|
||||
* Web question plugin, node half: enabling this UI feature also exposes the
|
||||
* model-facing ask_user_question tool on the host composition.
|
||||
* Web question plugin, node half.
|
||||
*
|
||||
* Deliberately empty. Mounting `ask_user_question` here put it in the tools
|
||||
* registry's GLOBAL layer, so every agent saw it no matter which preset
|
||||
* composed it — a two-tool benchmark preset actually presented three, and a
|
||||
* locally authored `bash-only` preset presented two. Rendering a question is
|
||||
* a host UI capability; having the tool is an agent capability, and only a
|
||||
* preset decides that. The `tool-ask-user` row belongs in the presets that
|
||||
* want it (and in the TUI composition, which has no presets).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
/** Host services required by the model-facing tool. */
|
||||
export const inject = ['tools', 'userInteraction']
|
||||
|
||||
/**
|
||||
* Mount ask_user_question for hosts that selected the Web question plugin.
|
||||
* @param ctx - Host plugin context carrying tools and userInteraction.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
toolAskUser.apply(ctx)
|
||||
}
|
||||
/** Host plugin body — the model-facing tool is composed per preset, not here. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { apply, inject } from '../src/index.ts'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
let ctx: Context | undefined
|
||||
|
||||
@@ -13,16 +13,19 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ui-question node plugin', () => {
|
||||
it('exposes ask_user_question only for the selected Web feature lifecycle', async () => {
|
||||
it('mounts no model-facing tool', async () => {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const feature = ctx.plugin({ inject: [...inject], apply })
|
||||
await feature.await()
|
||||
expect(ctx.tools.get('ask_user_question')).toBeDefined()
|
||||
|
||||
await feature.dispose()
|
||||
await ctx.plugin({ apply }).await()
|
||||
|
||||
// Selecting the Web question FEATURE must not hand every agent the tool.
|
||||
// `ctx.tools.register` on an unscoped host context files into the global
|
||||
// layer, which merges into every agent's view regardless of the preset
|
||||
// that composed it — so a two-tool benchmark preset would really present
|
||||
// three. The `tool-ask-user` row belongs to the presets that want it.
|
||||
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,9 +29,6 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('GeneralSection', () => {
|
||||
const renderSlot = vi.fn(
|
||||
((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
|
||||
)
|
||||
const props: GeneralSectionComponentProps = { ...kit, renderSlot }
|
||||
const props: GeneralSectionComponentProps = { ...kit, renderSlot, close: vi.fn() }
|
||||
const view = render(<GeneralSection {...props} />)
|
||||
return { view, renderSlot }
|
||||
}
|
||||
|
||||
@@ -63,15 +63,18 @@
|
||||
}
|
||||
|
||||
/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
|
||||
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */
|
||||
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800 wide.
|
||||
One height for every section, taken from the viewport rather than the
|
||||
content: sections differ by hundreds of pixels (a settings list against the
|
||||
composition editor), and a content-sized panel would resize under the
|
||||
pointer on every nav click. Whatever does not fit scrolls in `.options`. */
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
height: min(800px, calc(100vh - 48px));
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: calc(100vh - 48px);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
function navIcon(id: string) {
|
||||
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
|
||||
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
|
||||
return <IconSettingsOutline16 className={css.navIcon} size={16} />
|
||||
}
|
||||
|
||||
@@ -84,7 +87,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.options}>
|
||||
{active !== undefined && renderSlot('settings.section', {}, { only: active })}
|
||||
{active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,12 +83,14 @@ export interface SettingsHeaderOwnerProps {
|
||||
|
||||
/**
|
||||
* Owner share of a settings section entry. The shell owns modal visibility
|
||||
* and navigation; sections receive nothing but the render site (their data
|
||||
* arrives through their own inject faces and stores).
|
||||
* and navigation; a section's data arrives through its own inject faces and
|
||||
* stores. `close` is the one shell affordance a section receives, for flows
|
||||
* that leave settings altogether (starting a session from a section) — the
|
||||
* onboarding coordinator's `openSection`/`complete` precedent, inverted.
|
||||
*/
|
||||
export interface SettingsSectionOwnerProps {
|
||||
/** Marker field: section owner props are intentionally empty. */
|
||||
children?: never
|
||||
/** Close the settings panel (the shell owns the open state). */
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** Owner share of the currently active settings-backed onboarding step. */
|
||||
|
||||
@@ -24,6 +24,7 @@ function mount({
|
||||
rows = [
|
||||
{ id: 'general', order: 0, label: 'General' },
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
{ id: 'agent-presets', order: 20, label: 'Agent presets' },
|
||||
],
|
||||
steps = [
|
||||
{ id: 'welcome', order: -100 },
|
||||
|
||||
6
packages/core/agent-tool-mode/README.i18n.yaml
Normal file
6
packages/core/agent-tool-mode/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-tool-mode/README.md
|
||||
README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2
|
||||
README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce
|
||||
31
packages/core/agent-tool-mode/README.md
Normal file
31
packages/core/agent-tool-mode/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# dsh-agent-tool-mode
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The row an [agent preset](../../preset/agent-presets/README.md) carries to say which form of its tools the model sees: `native` (every schema), `code` (only `run_code` plus a generated TypeScript SDK), or `both`.
|
||||
|
||||
## Why a row rather than a registry
|
||||
|
||||
The tool registry cannot move into a preset. Its consumers are all host-plane — [`dsh-agent-loop`](../agent-loop/README.md) reads its scheduler, [`dsh-apiproxy`](../../host/apiproxy/README.md) reads its presenters to render tool cards, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it.
|
||||
|
||||
What a preset can own is the **presentation** of that registry. `ctx.tools.presentAs()` declares it for the mounting agent alone, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. The deployment's `mode` on the [`dsh-tools`](../tools/README.md) row remains the default that agents declaring nothing get.
|
||||
|
||||
## What it does
|
||||
|
||||
`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, which is a host-plane service ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)): a preset selecting Code Mode against a deployment composing no runtime then holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. The alternative — applying optimistically — moves the failure to the session's first request, where the operator can act on neither the preset nor the composition.
|
||||
|
||||
`mode` is required rather than defaulted, because a preset without this row already gets the deployment default; an omitted value would mean the row was composed for nothing.
|
||||
|
||||
One agent declares one presentation. A second declaration in the same composition is refused rather than merged: two answers to "which form does the model see" is a contradiction, not an override.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the presentation is fixed when the agent is composed, so its request prefix is stable for the session's life.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset.
|
||||
31
packages/core/agent-tool-mode/README.zh.md
Normal file
31
packages/core/agent-tool-mode/README.zh.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# dsh-agent-tool-mode
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[agent preset](../../preset/agent-presets/README.md) 用来声明「模型看到的工具是哪一种形态」的那一行:`native`(全部 schema)、`code`(只有 `run_code` 加一份生成的 TypeScript SDK)或 `both`。
|
||||
|
||||
## 为什么是一行插件,而不是把注册表搬下来
|
||||
|
||||
工具注册表搬不进 preset。它的消费者全在宿主平面——[`dsh-agent-loop`](../agent-loop/README.md) 读它的调度器,[`dsh-apiproxy`](../../host/apiproxy/README.md) 读它的 presenter 来渲染工具卡,每个工具插件都往里注册——而一个服务只有在**所有**消费者一起下沉时才能下沉。
|
||||
|
||||
preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs()` 只为正在挂载的那个 agent 声明,于是一个 Code Mode 会话可以和多个 native 会话同进程并存,各自看到各自的清单。[`dsh-tools`](../tools/README.md) 那一行上的 `mode` 仍然是默认值,供未作声明的 agent 使用。
|
||||
|
||||
## 它做什么
|
||||
|
||||
`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)):若某个 preset 在未组装运行时的部署上选择 Code Mode,本行就停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。另一种做法——先乐观应用——会把失败推迟到该会话的第一次请求,那时操作者对 preset 和组装都已无从下手。
|
||||
|
||||
`mode` 是必填而非有默认值:不带这一行的 preset 本来就会拿到部署默认值,省略它等于这一行白组装了。
|
||||
|
||||
一个 agent 只声明一次呈现方式。同一份组装里的第二次声明会被拒绝而不是合并:对「模型看到哪种形态」给出两个答案是矛盾,不是覆盖。
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
没有直接的失效影响;呈现方式在 agent 组装时即固定,因此其请求前缀在该会话的整个生命周期内保持稳定。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **运行时仍在宿主平面** —— preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。
|
||||
45
packages/core/agent-tool-mode/package.json
Normal file
45
packages/core/agent-tool-mode/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-tool-mode",
|
||||
"description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
70
packages/core/agent-tool-mode/src/index.ts
Normal file
70
packages/core/agent-tool-mode/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Agent-plane presentation selector: the row an agent preset carries to say
|
||||
* which form of its tools the model sees.
|
||||
*
|
||||
* The tool registry itself stays on the host plane — the agent loop's
|
||||
* scheduler, the API proxy's presenters, and every tool plugin are all its
|
||||
* consumers, so it cannot move into a preset. What a preset CAN own is the
|
||||
* presentation: `ctx.tools.presentAs()` declares it for the mounting agent
|
||||
* alone, so a Code Mode agent runs beside native ones in one process.
|
||||
*
|
||||
* A code mode needs a TypeScript code runtime, which is a host-plane service
|
||||
* ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)).
|
||||
* This row therefore waits for it rather than assuming it: a preset selecting
|
||||
* Code Mode against a deployment that composes no runtime fails at mount, named
|
||||
* in the preset's own activation audit, instead of at the first prompt.
|
||||
* @module @deepseek-ai/dsh-agent-tool-mode
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'tool-mode'
|
||||
|
||||
/**
|
||||
* Required services. `codeRuntime` is NOT listed: a `native` row must mount in
|
||||
* a deployment that composes no runtime, and the mode-dependent wait is
|
||||
* declared inside {@link apply} instead.
|
||||
*/
|
||||
export const inject = ['tools']
|
||||
|
||||
/** Plugin config. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The form this agent's model sees. `native` sends every visible schema,
|
||||
* `code` sends only `run_code` plus a generated SDK, `both` sends both.
|
||||
* Required rather than defaulted: the deployment default is what a preset
|
||||
* without this row already gets, so an omitted value would mean the row was
|
||||
* composed for nothing.
|
||||
*/
|
||||
mode: ToolPresentationMode
|
||||
}
|
||||
|
||||
/** Runtime schema. */
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union(['native', 'code', 'both'] as const).required(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Declare this agent's tool presentation.
|
||||
* @param ctx - the mounting agent's scope context.
|
||||
* @param config - the selected presentation.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// `presentAs` is itself the effect — it registers through the calling
|
||||
// context and hands back that exact disposer — so the declaration unwinds
|
||||
// with this row without a second wrapper owning it.
|
||||
if (config.mode === 'native') {
|
||||
ctx.tools.presentAs('native')
|
||||
return
|
||||
}
|
||||
// The wait is the loud failure: an entry still pending on `codeRuntime` is
|
||||
// what `dsh-agent-presets` reports as an unusable row, naming this id.
|
||||
ctx.inject(['codeRuntime'], (runtimeCtx: Context) => {
|
||||
runtimeCtx.tools.presentAs(config.mode)
|
||||
})
|
||||
}
|
||||
32
packages/core/agent-tool-mode/src/invariant.ts
Normal file
32
packages/core/agent-tool-mode/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-mode`.
|
||||
* @module @deepseek-ai/dsh-agent-tool-mode/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-mode-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package makes exactly one scoped call into
|
||||
* `ctx.tools` and owns no event or snapshot of its own; the relation it
|
||||
* establishes — which presentation one agent's assembly uses — is the tool
|
||||
* registry's to hold, and `dsh-tools` observes it there.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
129
packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts
Normal file
129
packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The row an agent preset carries to pick its tool presentation. What it owes
|
||||
* its caller: the choice reaches THIS agent and no other, it unwinds with the
|
||||
* agent, and a code mode composed against a deployment with no code runtime
|
||||
* stops at mount — where a preset's activation audit can name it — rather
|
||||
* than at the first prompt assembly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-mode'
|
||||
|
||||
/** A runtime that never runs anything: presentation never dispatches. */
|
||||
class StubRuntime extends CodeRuntime {
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'stub'
|
||||
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
return Promise.resolve({ logs: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/** A host plane with one tool, optionally carrying a code runtime. */
|
||||
async function host(options: { runtime?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
await ctx.plugin(ToolRegistry, {})
|
||||
if (options.runtime !== false) await ctx.plugin(StubRuntime)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'Echo tool.',
|
||||
parameters: { value: { type: 'string', required: true } },
|
||||
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
||||
execute: args => Promise.resolve(args.value),
|
||||
}))
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mount the row under one agent's scope, as a preset subtree does. */
|
||||
async function mount(ctx: Context, config: Config, id = 'agent') {
|
||||
const agent = { id: SessionId(id) } as Agent
|
||||
let inner!: Context
|
||||
const fiber = ctx.plugin(Object.assign((host: Context) => {
|
||||
inner = createScope(host, agent).ctx
|
||||
}, { inject: ['tools', 'systemPrompt'] }))
|
||||
await fiber.await()
|
||||
const row = inner.plugin({ name, inject: [...inject], Config, apply }, config)
|
||||
await row.await()
|
||||
return { agent, fiber, row }
|
||||
}
|
||||
|
||||
describe('the tool-mode row', () => {
|
||||
it('declares the services it uses without holding a code runtime hostage', () => {
|
||||
// A `native` row must mount where no runtime is composed, so the wait is
|
||||
// conditional inside apply rather than static metadata.
|
||||
expect(inject).toEqual(['tools'])
|
||||
})
|
||||
|
||||
it('gives its own agent Code Mode and leaves the rest native', async () => {
|
||||
const ctx = await host()
|
||||
const coded = await mount(ctx, { mode: 'code' }, 'coded')
|
||||
const plain = await mount(ctx, { mode: 'native' }, 'plain')
|
||||
|
||||
const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
||||
const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent })
|
||||
|
||||
expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo')
|
||||
expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('presents both forms when asked for both', async () => {
|
||||
const ctx = await host()
|
||||
const { agent } = await mount(ctx, { mode: 'both' })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME])
|
||||
})
|
||||
|
||||
it('restores the deployment default when the agent unloads', async () => {
|
||||
const ctx = await host()
|
||||
const { agent, row } = await mount(ctx, { mode: 'code' })
|
||||
|
||||
await row.dispose()
|
||||
|
||||
// HMR safety: the preset subtree is torn down with its agent, and the
|
||||
// presentation must go with it rather than outliving the composition.
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
|
||||
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for a code runtime the deployment does not compose', async () => {
|
||||
const ctx = await host({ runtime: false })
|
||||
|
||||
const { agent, row } = await mount(ctx, { mode: 'code' })
|
||||
|
||||
// Pending, not applied: `dsh-agent-presets` rejects a mount holding a row
|
||||
// that never reached a usable state, naming this id — so the preset fails
|
||||
// where the operator can act, instead of at the first request.
|
||||
expect(row.ctx.get('codeRuntime')).toBeUndefined()
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('applies once the runtime arrives', async () => {
|
||||
const ctx = await host({ runtime: false })
|
||||
const { agent } = await mount(ctx, { mode: 'code' })
|
||||
|
||||
await ctx.plugin(StubRuntime)
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
})
|
||||
|
||||
it('requires a mode rather than defaulting one', () => {
|
||||
// An omitted value would mean the row was composed for nothing: a preset
|
||||
// without this row already gets the deployment default.
|
||||
expect(() => Config({} as never)).toThrow()
|
||||
})
|
||||
})
|
||||
27
packages/core/agent-tool-mode/tsconfig.json
Normal file
27
packages/core/agent-tool-mode/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export interface CreateAgentOptions {
|
||||
readonly seedLength?: number
|
||||
readonly origin?: 'subagent'
|
||||
readonly delegationDepth?: number
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
/**
|
||||
* Initial replay/fork history. A fork supplies a balanced completed-turn
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/scope/README.md
|
||||
README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8
|
||||
README.zh.md: 019e4c59dd788866e26b3b8a20b0023999f35ed2
|
||||
README.md: a8fbe97ae3b59f223bb52e44860439803fda420c
|
||||
README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents.
|
||||
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either.
|
||||
|
||||
## Public API
|
||||
|
||||
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`).
|
||||
- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal.
|
||||
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first.
|
||||
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
|
||||
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
|
||||
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
|
||||
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff its tag is the key or an ancestor of it; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
|
||||
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
|
||||
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates and stays chain-blind (a scope's OWN contributions — restrictions, guards — must not silently pick up an ancestor's), `chainLayers()` returns existing overlays farthest-ancestor-first, `merge()` materializes insertion-ordered named shadows along the chain, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
|
||||
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
|
||||
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
|
||||
|
||||
@@ -32,5 +33,5 @@ Handing out a scoped context hands out the minting plugin's service-resolution s
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
|
||||
- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets.
|
||||
- **A context carries one nearest scope key** — the hierarchy lives in the key-level parent relation, not in context tags; nested scope CONTEXTS still shadow to a single tag, and multi-membership policy sets remain unsupported.
|
||||
- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder.
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。
|
||||
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。
|
||||
|
||||
## 公开 API
|
||||
|
||||
- `createScope(ctx: Context, key: ScopeKey): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。
|
||||
- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。
|
||||
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。
|
||||
- `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。
|
||||
- `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。
|
||||
- `Scope.dispose(): Promise<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。
|
||||
@@ -15,7 +16,7 @@
|
||||
- `Scoped<T>`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。
|
||||
- `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。
|
||||
- `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合约定;`isEmpty()` 控制带作用域层的回收。
|
||||
- `ScopedLayers<L>`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。
|
||||
- `ScopedLayers<L>`:持有一个立即构造的全局层与惰性的精确作用域层。`peek()` 从不创建且刻意不看链(某作用域**自己**的贡献——限制、守卫——不得悄悄继承祖先的),`chainLayers()` 按最远祖先在前返回已存在的各层,`merge()` 沿链物化按插入序的具名遮蔽,`effect()` 从同一上下文推导可见性与所有权,并返回精确的 Cordis disposer。
|
||||
- `NamedEntries<V>`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。
|
||||
- `AnonymousEntries<V>`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。
|
||||
|
||||
@@ -32,5 +33,5 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。
|
||||
- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。
|
||||
- **一个上下文只携带一个最近的作用域键**:层级关系存在于键级父关系中而非上下文标签里;嵌套作用域**上下文**仍遮蔽为单一标签,多成员策略集仍不受支持。
|
||||
- **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此,若作用域创建者提供的服务范围较宽,持有者之后也无法将其收窄。
|
||||
|
||||
@@ -29,6 +29,78 @@ export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
|
||||
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
|
||||
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
|
||||
|
||||
/**
|
||||
* The enclosing scope of each key. One relation powers both directions of
|
||||
* scope nesting: registration views inherit DOWN the chain (a child scope
|
||||
* sees its ancestors' layers — {@link ScopedLayers}), and event admission
|
||||
* extends UP it (a listener tagged with an ancestor receives events dispatched
|
||||
* to a descendant key — {@link scopeTarget}).
|
||||
*/
|
||||
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
|
||||
|
||||
/** The privileged handle to move one scope key's parent link. */
|
||||
export interface ScopeParentBinding {
|
||||
/**
|
||||
* Re-link the bound key to a different parent, with the same cycle check as
|
||||
* the bind. Valid only while nothing produced under the old parent is
|
||||
* retained — the blank-session recompose contract, which the holder upholds
|
||||
* because this relation cannot see what a session logged.
|
||||
* @param parent - the new enclosing scope key.
|
||||
*/
|
||||
rebind(parent: ScopeKey): void
|
||||
}
|
||||
|
||||
/** Cycle-checked write shared by the bind and every rebind. */
|
||||
function linkScopeParent(key: ScopeKey, parent: ScopeKey): void {
|
||||
for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {
|
||||
if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')
|
||||
}
|
||||
scopeParents.set(key, parent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `parent` as `key`'s enclosing scope, once.
|
||||
*
|
||||
* A key that already has a parent throws: there is no open re-link path, so a
|
||||
* scope's ancestry cannot be moved by anyone but the original binder, who
|
||||
* alone receives the {@link ScopeParentBinding}. A link that would close a
|
||||
* cycle is rejected, because every chain consumer walks parents to the root.
|
||||
* @param key - the child scope key.
|
||||
* @param parent - its enclosing scope key.
|
||||
* @returns the binding that alone may re-link this key.
|
||||
*/
|
||||
export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
|
||||
if (scopeParents.has(key)) {
|
||||
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
|
||||
}
|
||||
linkScopeParent(key, parent)
|
||||
return {
|
||||
rebind(next: ScopeKey): void {
|
||||
linkScopeParent(key, next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one key's enclosing scope.
|
||||
* @param key - the scope key to inspect.
|
||||
* @returns its parent key, or `undefined` for a root scope.
|
||||
*/
|
||||
export function scopeParentOf(key: ScopeKey): ScopeKey | undefined {
|
||||
return scopeParents.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain from a key to its root ancestor.
|
||||
* @param key - the starting key, or `undefined` for the empty chain.
|
||||
* @returns keys nearest-first: `[key, parent, grandparent, …]`.
|
||||
*/
|
||||
export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] {
|
||||
const chain: ScopeKey[] = []
|
||||
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor)
|
||||
return chain
|
||||
}
|
||||
|
||||
/** A minted registration scope and its quiescent disposal boundaries. */
|
||||
export interface Scope {
|
||||
/** Context through which scope-owned registrations are made. */
|
||||
@@ -48,14 +120,22 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
/** Shared no-op plugin used as the backing scope fiber. */
|
||||
function scope(): void {}
|
||||
|
||||
/** Options accepted by {@link createScope}. */
|
||||
export interface CreateScopeOptions {
|
||||
/** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
|
||||
parent?: ScopeKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
|
||||
* dependency surface and owns every registration made through it.
|
||||
* @param ctx - active context whose dependency surface the scope inherits.
|
||||
* @param key - opaque identity used for listener routing.
|
||||
* @param options - optional scope-chain placement.
|
||||
* @returns the scoped context and exact/shared disposal boundaries.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: ScopeKey): Scope {
|
||||
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
|
||||
if (options?.parent !== undefined) bindScopeParent(key, options.parent)
|
||||
const fiber = ctx.plugin(scope)
|
||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||
let disposing: Promise<void> | undefined
|
||||
@@ -77,7 +157,12 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
|
||||
|
||||
/**
|
||||
* Build an opaque receiver that preserves the base filter, admits untagged
|
||||
* listeners globally, and admits tagged listeners only for a matching key.
|
||||
* listeners globally, and admits tagged listeners for a matching key or any
|
||||
* of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing
|
||||
* scope receives every descendant scope's events, which is what lets one
|
||||
* standing composition observe each of the agents composed under it. A tag
|
||||
* BELOW the dispatch key stays excluded — events flow up the chain, never
|
||||
* down.
|
||||
* @param base - subject or service whose existing Cordis filter is preserved.
|
||||
* @param key - routed scope identity, or `undefined` for an unscoped subject.
|
||||
* @returns a carrier whose subject remains available only through event arguments.
|
||||
@@ -88,7 +173,11 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
|
||||
[CordisContext.filter](ctx: Context): boolean {
|
||||
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
if (tag === undefined) return true
|
||||
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
|
||||
if (cursor === tag) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
carrierKeys.set(carrier, key)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { scopeOf } from './index.ts'
|
||||
import { scopeChainOf, scopeOf } from './index.ts'
|
||||
import type { ScopeKey } from './index.ts'
|
||||
|
||||
/** One scope's aggregate contribution to a registry. */
|
||||
@@ -170,7 +170,10 @@ export class ScopedLayers<L extends ScopeLayer> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an existing exact-scope overlay.
|
||||
* Read an existing exact-scope overlay. Deliberately chain-blind: callers
|
||||
* addressing one scope's OWN contributions (its restrictions, its guards)
|
||||
* must not silently pick up an ancestor's — use {@link chainLayers} where
|
||||
* inheritance is the point.
|
||||
* @param scope - exact scope key; `undefined` denotes no overlay.
|
||||
* @returns the existing scoped layer, or `undefined` without creating one.
|
||||
*/
|
||||
@@ -180,8 +183,25 @@ export class ScopedLayers<L extends ScopeLayer> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize global named entries followed by exact-scope shadows.
|
||||
* @param scope - exact viewing scope, or `undefined` for the global view.
|
||||
* Existing overlays along the scope's parent chain ({@link scopeChainOf}),
|
||||
* farthest ancestor first and the exact scope last, so a caller layering
|
||||
* them in order gives the nearest scope the final word.
|
||||
* @param scope - viewing scope, or `undefined` for no overlays.
|
||||
* @returns the existing layers, nearest last; absent overlays are skipped.
|
||||
*/
|
||||
chainLayers(scope: ScopeKey | undefined): L[] {
|
||||
const layers: L[] = []
|
||||
for (const key of scopeChainOf(scope).reverse()) {
|
||||
const layer = this.scoped.get(key)
|
||||
if (layer !== undefined) layers.push(layer)
|
||||
}
|
||||
return layers
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize global named entries followed by scope-chain shadows,
|
||||
* farthest ancestor first, so the nearest scope's entry wins a name.
|
||||
* @param scope - viewing scope, or `undefined` for the global view.
|
||||
* @param pick - select the named table from a layer.
|
||||
* @returns an insertion-ordered effective map.
|
||||
*/
|
||||
@@ -190,9 +210,9 @@ export class ScopedLayers<L extends ScopeLayer> {
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V> {
|
||||
const merged = new Map(pick(this.global).entries())
|
||||
const layer = this.peek(scope)
|
||||
if (layer === undefined) return merged
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
for (const layer of this.chainLayers(scope)) {
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -153,3 +153,70 @@ describe('scopeTarget', () => {
|
||||
expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope parent chain', () => {
|
||||
it('links at mint, walks to the root, and rejects cycles', () => {
|
||||
const ctx = new Context()
|
||||
const preset = { kind: 'preset' }
|
||||
const agent = { kind: 'agent' }
|
||||
createScope(ctx, preset)
|
||||
createScope(ctx, agent, { parent: preset })
|
||||
|
||||
expect(scopeParentOf(agent)).toBe(preset)
|
||||
expect(scopeParentOf(preset)).toBeUndefined()
|
||||
expect(scopeChainOf(agent)).toEqual([agent, preset])
|
||||
expect(scopeChainOf(undefined)).toEqual([])
|
||||
expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/)
|
||||
expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/)
|
||||
})
|
||||
|
||||
it('re-links only through the binding held by the original binder', () => {
|
||||
const ctx = new Context()
|
||||
const presetA = { id: 'a' }
|
||||
const presetB = { id: 'b' }
|
||||
const agent = { id: 'agent' }
|
||||
createScope(ctx, presetA)
|
||||
createScope(ctx, presetB)
|
||||
const binding = bindScopeParent(agent, presetA)
|
||||
createScope(ctx, agent)
|
||||
|
||||
// A bound key cannot be re-bound from the outside; only the binding moves it.
|
||||
expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/)
|
||||
binding.rebind(presetB)
|
||||
|
||||
expect(scopeChainOf(agent)).toEqual([agent, presetB])
|
||||
// The rebind keeps the cycle check: a parent may not adopt its ancestor.
|
||||
const child = { id: 'child' }
|
||||
const childBinding = bindScopeParent(child, agent)
|
||||
void childBinding
|
||||
expect(() => { binding.rebind(child) }).toThrow(/cycle/)
|
||||
})
|
||||
|
||||
it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => {
|
||||
const ctx = new Context()
|
||||
const preset = { kind: 'preset' }
|
||||
const agent = { kind: 'agent' }
|
||||
const other = { kind: 'other-preset' }
|
||||
const presetScope = createScope(ctx, preset)
|
||||
const agentScope = createScope(ctx, agent, { parent: preset })
|
||||
const otherScope = createScope(ctx, other)
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never)
|
||||
presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never)
|
||||
agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never)
|
||||
otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never)
|
||||
|
||||
const emit = ctx as unknown as { emit: (carrier: object, type: string) => void }
|
||||
// Dispatch at the AGENT key: its own tag and its ancestor's admit; a
|
||||
// sibling root does not.
|
||||
emit.emit(scopeTarget({}, agent), 'probe/event')
|
||||
expect(seen.sort()).toEqual(['agent', 'preset', 'untagged'])
|
||||
|
||||
// Dispatch at the PRESET key: the agent-tagged listener sits BELOW the
|
||||
// dispatch key and stays excluded — events flow up the chain, not down.
|
||||
seen.length = 0
|
||||
emit.emit(scopeTarget({}, preset), 'probe/event')
|
||||
expect(seen.sort()).toEqual(['preset', 'untagged'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,6 +149,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
}
|
||||
if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') {
|
||||
throw new Error('session header agentPreset must be a string')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
@@ -898,6 +901,7 @@ export class SessionStore extends Service {
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.origin === undefined ? {} : { origin: meta.origin },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset },
|
||||
}
|
||||
return Session.create(sessionId, seed, header)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@ export interface SessionHeader {
|
||||
* resume — a runtime-only depth would reset a resumed child to top-level.
|
||||
*/
|
||||
readonly delegationDepth?: number
|
||||
/**
|
||||
* Id of the agent preset this session's agent was composed from, when the
|
||||
* deployment composes per session. Durable because the preset decides the
|
||||
* session's tools and prompt: a resume that restored a different composition
|
||||
* would replay history the model can no longer act on.
|
||||
*/
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +97,7 @@ export interface CreateSessionOptions {
|
||||
readonly seedLength?: number
|
||||
readonly origin?: 'subagent'
|
||||
readonly delegationDepth?: number
|
||||
readonly agentPreset?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1319,6 +1319,7 @@ describe('SessionStore', () => {
|
||||
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { agentPreset: 1 }, error: /agentPreset must be a string/ },
|
||||
]
|
||||
|
||||
for (const [index, { meta, error }] of cases.entries()) {
|
||||
|
||||
@@ -110,6 +110,17 @@ export interface PromptAssembly {
|
||||
variables: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployment persona's section name and order. Exported because a
|
||||
* composition can replace this slot — an agent preset shadows the
|
||||
* deployment's persona with its own — and both sides naming the same section
|
||||
* is what makes the replacement work rather than duplicate.
|
||||
*/
|
||||
export const PERSONA_SECTION = 'deployment:persona'
|
||||
|
||||
/** Prompt order of the persona slot; the first section a model reads. */
|
||||
export const PERSONA_ORDER = 0
|
||||
|
||||
/** Valid variable names: how they are written between the braces. */
|
||||
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
@@ -337,8 +348,8 @@ export class SystemPrompt extends Service {
|
||||
})
|
||||
}
|
||||
this.section({
|
||||
name: 'deployment:persona',
|
||||
order: 0,
|
||||
name: PERSONA_SECTION,
|
||||
order: PERSONA_ORDER,
|
||||
// The fallback narrows the optional input type; the schema already defaults it.
|
||||
text: config.persona ?? '',
|
||||
})
|
||||
@@ -429,9 +440,11 @@ export class SystemPrompt extends Service {
|
||||
for (const [name, provider] of this.layers.global.variables.entries()) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const scopedVariables = this.layers.peek(scope)?.variables
|
||||
for (const [name, provider] of scopedVariables?.entries() ?? []) {
|
||||
variables[name] = provider(context)
|
||||
// Scope-chain variables, farthest first, so the nearest scope wins a name.
|
||||
for (const layer of this.layers.chainLayers(scope)) {
|
||||
for (const [name, provider] of layer.variables.entries()) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
}
|
||||
// Scoped sections shadow globals before the stable order sort.
|
||||
const sectionByName = this.layers.merge(scope, layer => layer.sections)
|
||||
@@ -439,7 +452,7 @@ export class SystemPrompt extends Service {
|
||||
// Validate order against pre-restriction names while collecting visible schemas.
|
||||
const providers = [
|
||||
...this.layers.global.toolProviders.values(),
|
||||
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
|
||||
...this.layers.chainLayers(scope).flatMap(layer => [...layer.toolProviders.values()]),
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
|
||||
README.md: b1de96293f8ec823ec52d6142a46de877f7fc5e6
|
||||
README.zh.md: fa42f7c02a09579bd7b1c995246696d8808889de
|
||||
README.md: f3d1b4741c7fde64669794d079c36a18e633c0c1
|
||||
README.zh.md: d3054372095ef0cfdabc6cf80e0faa41a3b12d4c
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`.
|
||||
|
||||
## Service: `ToolRegistry` (ctx key: `tools`)
|
||||
|
||||
@@ -13,11 +13,12 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
@@ -190,6 +191,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
|
||||
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
|
||||
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
|
||||
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own).
|
||||
- **Code Mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (TypeScript or Python); scoped restrictions/shadows and `presentAs` choose each agent's visible bindings and their form, but within one agent no tool can be native-only while another is code-only.
|
||||
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
|
||||
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user