Merge remote-tracking branch 'origin/master' into fix/subagent-empty-terminal-message-output
# Conflicts: # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md
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 {
|
||||
|
||||
@@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
/**
|
||||
* The pwsh invocation argv for one resolved spec — the argv-level seam a
|
||||
* confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of
|
||||
* `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see
|
||||
* `@deepseek-ai/dsh-pwsh-sandbox`).
|
||||
*/
|
||||
protected argv(spec: BashExecSpec): string[] {
|
||||
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
|
||||
}
|
||||
|
||||
/** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(
|
||||
spec: BashExecSpec,
|
||||
stdoutMaxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
argv: readonly string[],
|
||||
): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
|
||||
argv: [...argv],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return this.runArgv(spec, this.argv(spec))
|
||||
}
|
||||
|
||||
/** Foreground run of an exact argv (the confining subclass re-wraps it). */
|
||||
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
@@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return this.startArgv(spec, this.argv(spec))
|
||||
}
|
||||
|
||||
/** Background start of an exact argv (the confining subclass re-wraps it). */
|
||||
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
@@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
this.onProcessDone(proc, spawnFailureNote, true, error)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
@@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the protected extension point for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the
|
||||
* pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
* @param _spawnFailed - whether the spawn rejected before any process existed.
|
||||
* @param _spawnError - the spawn rejection, when `_spawnFailed`.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
|
||||
6
packages/bash/pwsh-sandbox/README.i18n.yaml
Normal file
6
packages/bash/pwsh-sandbox/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/bash/pwsh-sandbox/README.md
|
||||
README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2
|
||||
README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec
|
||||
34
packages/bash/pwsh-sandbox/README.md
Normal file
34
packages/bash/pwsh-sandbox/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt.
|
||||
|
||||
The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy).
|
||||
|
||||
## Behavior
|
||||
|
||||
- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`.
|
||||
- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Confinement works, denial surfaces as command failure
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool.
|
||||
|
||||
#### Token effect
|
||||
|
||||
No model-visible text beyond the command's stderr and the tool layer's standard denial surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None directly; the denial surface belongs to the tool layer.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap.
|
||||
- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package).
|
||||
34
packages/bash/pwsh-sandbox/README.zh.md
Normal file
34
packages/bash/pwsh-sandbox/README.zh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。
|
||||
|
||||
执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。
|
||||
|
||||
## 行为
|
||||
|
||||
- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。
|
||||
- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 隔离生效,拒绝以命令失败呈现
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;拒绝呈现面属于工具层。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。
|
||||
- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。
|
||||
45
packages/bash/pwsh-sandbox/package.json
Normal file
45
packages/bash/pwsh-sandbox/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"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",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pwsh-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Internal sandbox-result classification helpers — deliberate call-for-call
|
||||
* mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of
|
||||
* the bash consumer shares the identical classification dialect).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox/helpers
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's denial dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive denial substrings from the active wrap.
|
||||
* @returns whether the failed run matches that denial dialect.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a non-zero exit against case-insensitive stderr signatures.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text.
|
||||
* @param signatures - substrings identifying the selected backend's dialect.
|
||||
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
||||
*/
|
||||
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
189
packages/bash/pwsh-sandbox/src/index.ts
Normal file
189
packages/bash/pwsh-sandbox/src/index.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Sandbox-consuming PowerShell executor — the pwsh twin of
|
||||
* `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through
|
||||
* `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner
|
||||
* chain), inherits local process mechanics, and reports the selected mode,
|
||||
* enforcement, and denial facts. Positive runner-launch evidence means the
|
||||
* command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
|
||||
* background processes carry `runnerFailed`; other spawn rejections retain
|
||||
* local-executor semantics. The tool layer owns the escalation approval flow
|
||||
* through `ctx.approval`; this executor reports the sandbox facts the tool
|
||||
* renders.
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
ConfinedArgv,
|
||||
ConfinedSandboxMode,
|
||||
RunnerFailureRule,
|
||||
SandboxEnforcement,
|
||||
SandboxExecutionPolicy,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and fallback `workspace-write` root — is NOT here: it lives
|
||||
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
|
||||
* each calling session's mode and cwd for every enforcing capability. The
|
||||
* runner choice is likewise the `ctx.sandbox` provider's config, not this
|
||||
* executor's.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local pwsh executor and requires a
|
||||
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the
|
||||
* sandbox denial rendering and escalation surface (see the
|
||||
* pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's
|
||||
* resolved policy; direct calls fall back to deployment policy.
|
||||
* `result.sandbox` reports the mode, enforcement, and denial facts the tool
|
||||
* renders.
|
||||
*/
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */
|
||||
export class SandboxPwshExecutor extends PwshLocalExecutor {
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
/**
|
||||
* Per-process confinement facts retained until settlement. Providers may
|
||||
* vary enforcement and diagnostic dialect between overlapping calls, so a
|
||||
* shared latest-wrap value would classify a process against the wrong facts.
|
||||
* Unconfined processes have no entry.
|
||||
*/
|
||||
private readonly processFacts = new Map<BashProcess, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// The default mode is the capability fact used for schema advertisement;
|
||||
// actual tool executions carry their resolved per-call policy.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a complete per-call policy onto the spec. Tool calls supply the
|
||||
* calling session's resolved mode and root; lower-level callers fall back to
|
||||
* the deployment policy.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec, { ...policy, mode })
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await this.runArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// An upstream abort remains cancellation even when it prevents spawn.
|
||||
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Runner failure outranks denial because the command did not run. Carry
|
||||
// the matched fatal line, not an informational line that preceded it.
|
||||
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
|
||||
if (runnerFailure !== undefined) {
|
||||
throw new SandboxUnavailableError(mode, runnerFailure.detail)
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec, { ...policy, mode })
|
||||
let proc: BashProcess
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, {
|
||||
mode,
|
||||
enforcement,
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp per-process sandbox facts before `done` settles. Full-access
|
||||
* processes have no facts; signal deaths are not denials.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnFailed
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
enforcement: facts.enforcement,
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param spec - resolved execution spec whose pwsh argv is confined.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(this.argv(spec), policy)
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export default SandboxPwshExecutor
|
||||
30
packages/bash/pwsh-sandbox/src/invariant.ts
Normal file
30
packages/bash/pwsh-sandbox/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`.
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pwsh-sandbox-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or
|
||||
* mutable data relation beyond contracts enforced at its owning seams.
|
||||
*/
|
||||
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 */
|
||||
111
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
111
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
|
||||
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
|
||||
* REAL pwsh spawns confined through the runner — the debug-instance
|
||||
* verification of both modes: read-only denies every write (not even NUL),
|
||||
* workspace-write allows the workspace and temp while denying escape writes,
|
||||
* and denial/classification facts ride the settled result.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { SandboxPwshExecutor } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let executor!: SandboxPwshExecutor
|
||||
|
||||
beforeAll(async () => {
|
||||
// The escape probe must live OUTSIDE every legitimately granted tree: the
|
||||
// provider's workspace-write grants the workspace plus the REAL temp dir
|
||||
// (the 'backend-defined temp area', same as Landlock granting /tmp), so a
|
||||
// scratch dir under temp would inherit the grant and the probe would be a
|
||||
// false pass. A mkdtemp under the profile is removed by afterAll.
|
||||
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxPwshExecutor, {})
|
||||
executor = ctx.bash as SandboxPwshExecutor
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
|
||||
// A self-caught denial keeps the command exit 0: no denial fact.
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
|
||||
// A raw failing write must classify as a denial of the ACL dialect.
|
||||
const denied = await executor.run(executor.resolve({
|
||||
command: `Set-Content -Path '${escapeFile}' -Value x`,
|
||||
sandboxPolicy: policy,
|
||||
}))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 60_000)
|
||||
|
||||
it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
}, 60_000)
|
||||
})
|
||||
326
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
326
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service
|
||||
* makes wrapping, policy hand-off, fail-closed propagation, and fact stamping
|
||||
* deterministic; real-provider integration lives in `tests/acl.e2e.ts`.
|
||||
* Requires pwsh for the integration block (skips without it — same gate as
|
||||
* pwsh-local's suites); the helpers block is pure and always runs.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { SandboxPwshExecutor } from '../src/index.ts'
|
||||
import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts'
|
||||
|
||||
// The same probe pwsh-local's suites and the vitest coverage exemption use:
|
||||
// spawnSync never throws on a missing binary (it reports status null), and
|
||||
// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth.
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-'))
|
||||
|
||||
/** One recorded provider call: the argv handed over and the policy it rode with. */
|
||||
interface ConfineCall {
|
||||
argv: string[]
|
||||
policy: SandboxPolicy
|
||||
}
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] })
|
||||
|
||||
/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */
|
||||
function throwingSubprocessService(error: unknown): new (ctx: Context) => Service {
|
||||
return class extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subprocess')
|
||||
}
|
||||
|
||||
spawn(): never {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(
|
||||
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
|
||||
subprocess: new (ctx: Context) => Service = LocalSubprocessService,
|
||||
): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> {
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
calls.push({ argv: [...argv], policy })
|
||||
return behavior(argv, policy)
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir })
|
||||
await ctx.plugin(subprocess)
|
||||
if (ctx.subprocess instanceof LocalSubprocessService) {
|
||||
ctx.subprocess.internals = { spillDir }
|
||||
}
|
||||
await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 })
|
||||
return { executor: ctx.bash as SandboxPwshExecutor, calls }
|
||||
}
|
||||
|
||||
describe('helpers (pure)', () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-'))
|
||||
afterAll(() => {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
const absolute = process.execPath
|
||||
const bare = 'node'
|
||||
const relative = './sandbox-runner'
|
||||
|
||||
it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => {
|
||||
for (const runnerProgram of [absolute, bare, relative]) {
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => {
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false)
|
||||
// An existing FILE (not a directory) workdir is unusable without throwing.
|
||||
const fileWorkdir = join(workdir, 'a-file')
|
||||
writeFileSync(fileWorkdir, 'x')
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
const rules: readonly RunnerFailureRule[] = [{
|
||||
allowedExitCodes: [127],
|
||||
fatalSignatures: ['fake-runner: '],
|
||||
informationalLines: ['fake-runner: partial enforcement'],
|
||||
}]
|
||||
|
||||
it('matches a fatal signature on a gated exit code, skipping informational lines', () => {
|
||||
expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules))
|
||||
.toEqual({ detail: 'fake-runner: profile refused' })
|
||||
})
|
||||
|
||||
it('rejects zero/null exits, gate mismatches, and empty signatures', () => {
|
||||
expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => {
|
||||
const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
|
||||
expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules))
|
||||
.toEqual({ detail: 'windows-acl-run: missing --workspace' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesSignature', () => {
|
||||
it('matches non-zero exits case-insensitively, never zero or signal exits', () => {
|
||||
expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true)
|
||||
expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true)
|
||||
expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false)
|
||||
expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false)
|
||||
expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
|
||||
// Denial device for the POSIX classification cases: a mode-0555 directory
|
||||
// INSIDE a temp scratch tree (the same device as bash-sandbox's suites) —
|
||||
// unit tests never attempt writes outside the system temp directory. On
|
||||
// win32 there is no POSIX mode denial; the real-sandbox denial coverage
|
||||
// lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths.
|
||||
const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-'))
|
||||
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555)
|
||||
const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')`
|
||||
|
||||
afterAll(() => {
|
||||
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755)
|
||||
rmSync(readOnlyDir, { recursive: true, force: true })
|
||||
rmSync(spillDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
|
||||
it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]
|
||||
expect(call?.policy).toEqual(RO)
|
||||
// The confined argv is the pwsh invocation, ready for a runner prefix.
|
||||
expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u)
|
||||
expect(call?.argv).toContain('-NonInteractive')
|
||||
expect(call?.argv.at(-1)).toContain('echo wrapped')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
expect(executor.sandboxMode).toBe('workspace-write')
|
||||
const result = await executor.run(executor.resolve({ command: 'echo fallback' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls[0]?.policy.mode).toBe('workspace-write')
|
||||
}, 30_000)
|
||||
|
||||
it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
}, 30_000)
|
||||
|
||||
it('an aborted caller signal outranks runner-spawn attribution', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller-cancel')
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal })))
|
||||
.rejects.toThrow('caller-cancel')
|
||||
}, 30_000)
|
||||
|
||||
// POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the
|
||||
// real-sandbox denial classification is covered by tests/acl.e2e.ts
|
||||
// (the ACL runner denies scratch paths — unit tests never leave temp).
|
||||
it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => {
|
||||
const { executor } = await setup()
|
||||
const result = await executor.run(executor.resolve({
|
||||
command: deniedWriteCommand,
|
||||
sandboxPolicy: RO,
|
||||
}))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
}, 30_000)
|
||||
|
||||
it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => {
|
||||
const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
|
||||
const { executor: closed } = await setup(() => ({
|
||||
argv: ['node', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}), throwingSubprocessService(attributable))
|
||||
await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
|
||||
const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
|
||||
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
|
||||
await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.rejects.toThrow('sync-emfile')
|
||||
}, 30_000)
|
||||
|
||||
it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => {
|
||||
const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
|
||||
const { executor: closed } = await setup(() => ({
|
||||
argv: ['node', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}), throwingSubprocessService(attributable))
|
||||
expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.toThrow(SandboxUnavailableError)
|
||||
|
||||
const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
|
||||
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
|
||||
expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.toThrow('sync-emfile-start')
|
||||
}, 30_000)
|
||||
|
||||
it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
}, 30_000)
|
||||
|
||||
it('background confined runs stamp clean facts at settlement', async () => {
|
||||
const { executor } = await setup()
|
||||
const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO }))
|
||||
await clean.done
|
||||
expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
// POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial
|
||||
// coverage lives in tests/acl.e2e.ts.
|
||||
it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => {
|
||||
const { executor } = await setup()
|
||||
const denied = executor.start(executor.resolve({
|
||||
command: deniedWriteCommand,
|
||||
sandboxPolicy: RO,
|
||||
}))
|
||||
await denied.done
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('background spawn rejections settle as runnerFailed facts', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO }))
|
||||
await proc.done
|
||||
expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
// The failure note surfaces through the read path.
|
||||
const read = proc.readOutput()
|
||||
expect(read.delta).toContain('spawn failed')
|
||||
}, 30_000)
|
||||
|
||||
it('danger-full-access background runs bypass confine and carry no facts', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const proc = executor.start(executor.resolve({
|
||||
command: 'echo full-bg',
|
||||
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' },
|
||||
}))
|
||||
await proc.done
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(proc.sandbox).toBeUndefined()
|
||||
}, 30_000)
|
||||
})
|
||||
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/pwsh-local"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/bash/tool-pwsh/README.md
|
||||
README.md: 7d8ee5fb69b71d8e8707d3e4ed07ebdda99f799f
|
||||
README.zh.md: 40984bbc36be4b5809e6ee4db21e52d842f50cdb
|
||||
README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8
|
||||
README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
|
||||
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
|
||||
|
||||
@@ -21,6 +21,8 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.bash.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. |
|
||||
| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
@@ -28,9 +30,9 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero
|
||||
|
||||
Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
@@ -78,7 +80,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -106,7 +108,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -118,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only.
|
||||
- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
|
||||
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `run_in_background` | boolean | 立即返回 task id;不适用超时。 |
|
||||
| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.bash.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed,不运行任何内容。 |
|
||||
| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
@@ -28,9 +30,9 @@
|
||||
|
||||
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
|
||||
|
||||
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。
|
||||
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、sandbox 拒绝(组合公开升级能力时带同轮次升级提示)、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。
|
||||
|
||||
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
|
||||
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
|
||||
|
||||
当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
|
||||
|
||||
@@ -78,7 +80,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be
|
||||
|
||||
#### What the model sees
|
||||
|
||||
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。
|
||||
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[sandbox: file access denied under <mode> mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -106,7 +108,7 @@ ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
#### What the model sees
|
||||
|
||||
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。
|
||||
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -118,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端仅限 Linux/macOS。
|
||||
- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。
|
||||
|
||||
@@ -30,9 +30,12 @@
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -46,12 +49,15 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
|
||||
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
|
||||
*
|
||||
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
|
||||
* foreground and `run_in_background` execution (background handles register
|
||||
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
|
||||
* through the shared `bash-env` registry, and the bash marker/truncation
|
||||
* rendering story. UI presentation mirrors the bash tool's too: a completed
|
||||
* foreground call is a terminal card with the parsed exit-status pill, using
|
||||
* the shared exit-status parse from `@deepseek-ai/dsh-bash`.
|
||||
* Behavior mirrors `dsh-tool-bash` call-for-call: foreground and
|
||||
* `run_in_background` execution (background handles register with the
|
||||
* generic `ctx.tasks` runtime), the managed `DSH_*` environment through the
|
||||
* shared `bash-env` registry, the per-call sandbox policy resolution (the
|
||||
* calling session's mode and cwd travel to the confining executor), the
|
||||
* sandbox-denial rendering with the same-turn escalation surface
|
||||
* (`sandbox_permissions` + `justification` resolved through
|
||||
* `ctx.approval`), and the bash marker/truncation rendering story. UI
|
||||
* presentation mirrors the bash tool's too: a completed foreground call is
|
||||
* a terminal card with the parsed exit-status pill, using the shared
|
||||
* exit-status parse from `@deepseek-ai/dsh-bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
@@ -19,16 +23,21 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { parseExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
|
||||
import type { RenderablePwshResult } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -57,6 +66,8 @@ interface PwshToolArgs {
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
|
||||
@@ -69,6 +80,7 @@ interface PwshForegroundResult {
|
||||
timeoutMs: number
|
||||
stdout: { text: string; truncated: boolean; spillPath?: string }
|
||||
stderr: { text: string; truncated: boolean; spillPath?: string }
|
||||
sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean }
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
|
||||
@@ -82,21 +94,54 @@ function validatePwshArgs(args: PwshToolArgs): void {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
|
||||
// the shared rule both enforcing families validate identically.
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function pwshDescription(backgroundEnabled: boolean): string {
|
||||
function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
|
||||
const background = backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'
|
||||
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
|
||||
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
|
||||
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
|
||||
+ background
|
||||
if (escalationModes.length === 0) return base
|
||||
// The CLM and named-pipe contracts below are Windows-restricted-token
|
||||
// behavior, but the gate is 'any confining executor is mounted'
|
||||
// (escalationModes non-empty). The conflation is safe today because every
|
||||
// shipped composition pairing tool-pwsh with a confining executor is
|
||||
// win32-only; a future POSIX pwsh-sandbox composition must gate both
|
||||
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
|
||||
// Agent Note).
|
||||
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
|
||||
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
|
||||
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
|
||||
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
|
||||
+ 'In the same modes, programs cannot open named pipes, so a command that captures another '
|
||||
+ 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default '
|
||||
+ '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns '
|
||||
+ 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: '
|
||||
+ 'do not retry the command another way — escalate the exact command once or restructure it to '
|
||||
+ 'avoid capturing output. '
|
||||
+ 'Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
||||
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
|
||||
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
|
||||
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
|
||||
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
|
||||
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
|
||||
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
|
||||
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
|
||||
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
|
||||
+ 'A rejected escalation is final for that command — stop and explain, never work around '
|
||||
+ 'it — but it does not forbid attempting or escalating other commands later.'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +174,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
|
||||
stdout: output(result.stdout),
|
||||
stderr: output(result.stderr),
|
||||
...result.sandbox !== undefined ? {
|
||||
sandbox: {
|
||||
mode: result.sandbox.mode,
|
||||
denied: result.sandbox.denied,
|
||||
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
||||
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
||||
},
|
||||
} : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +192,55 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
} as const
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
|
||||
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
||||
throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
|
||||
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
|
||||
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
||||
|
||||
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes, delegating the shared fail-closed sequence (strict
|
||||
* widening, channel resolution, outcome mapping) to
|
||||
* {@link approveEscalation}. This tool contributes only the composition
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the approval
|
||||
* ingredients. The shared policy resolver is required whenever the
|
||||
* executor advertises confinement, so a split composition fails at
|
||||
* tool-plugin load.
|
||||
*/
|
||||
const approvePwshEscalation = (
|
||||
mode: string,
|
||||
justification: string,
|
||||
exec: ToolExecution,
|
||||
standingPolicy: SandboxExecutionPolicy | undefined,
|
||||
): Promise<SandboxMode> => {
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
|
||||
return approveEscalation(
|
||||
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
|
||||
{
|
||||
approver: ctx.get('approval'),
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName: 'pwsh',
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pwsh',
|
||||
@@ -151,7 +251,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: pwshDescription(backgroundEnabled),
|
||||
description: pwshDescription(backgroundEnabled, escalationModes),
|
||||
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
|
||||
description: {
|
||||
@@ -166,7 +267,19 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
...escalationModes.length > 0 ? {
|
||||
sandbox_permissions: {
|
||||
type: 'string' as const,
|
||||
enum: [...escalationModes],
|
||||
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string' as const,
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
output: {
|
||||
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
|
||||
// consumers of one must accept the other (see the pwsh-tool-and-executor
|
||||
@@ -209,6 +322,16 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
sandbox: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: { type: 'string', required: true },
|
||||
denied: { type: 'boolean', required: true },
|
||||
enforcement: { type: 'string' },
|
||||
runnerFailed: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -218,18 +341,27 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderPwshResult(value),
|
||||
: renderPwshResult(value as RenderablePwshResult, escalationModes),
|
||||
}],
|
||||
},
|
||||
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
|
||||
async execute(args: PwshToolArgs, exec) {
|
||||
validatePwshArgs(args)
|
||||
// Description is display metadata; workdir defaults to the caller's session.
|
||||
const standingPolicy = resolveSandboxPolicy(exec)
|
||||
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
||||
: undefined
|
||||
const policy = approvedMode === undefined
|
||||
? standingPolicy
|
||||
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv: ctx.bashEnv.collect(exec),
|
||||
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
||||
@@ -241,15 +373,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The caller owns cancellation until ctx.tasks commits detached ownership.
|
||||
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
|
||||
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
|
||||
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
|
||||
if (exec.signal.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore end */
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'pwsh',
|
||||
@@ -260,7 +388,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
return {
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderPwshProcessRead(proc.readOutput()),
|
||||
readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
|
||||
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
|
||||
* stderr section, truncation notices with spill paths, then exit-status
|
||||
* markers. Non-zero exits are reported, not errored — the model decides how to
|
||||
* react; only infrastructure failures (spawn errors, aborts) surface as
|
||||
* isError results.
|
||||
* `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox
|
||||
* denial/runner-failure markers (with the same-turn escalation hint), and
|
||||
* truncation notices with spill paths, then exit-status markers. Non-zero
|
||||
* exits are reported, not errored — the model decides how to react; only
|
||||
* infrastructure failures (spawn errors, aborts) surface as isError
|
||||
* results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/render
|
||||
*/
|
||||
|
||||
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
@@ -27,6 +30,7 @@ export interface RenderablePwshResult {
|
||||
timeoutMs: number
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
sandbox?: BashSandboxInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +38,15 @@ export interface RenderablePwshResult {
|
||||
* stderr section, then exit-status markers, matching the bash tool's story —
|
||||
* a clean exit (0, no signal) produces no marker.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @param escalationModes - the escalation targets this composition advertises;
|
||||
* non-empty adds the same-turn escalation hint after a denial marker
|
||||
* (default `[]`: no hint).
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
export function renderPwshResult(
|
||||
result: RenderablePwshResult,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const out = streamText(result.stdout)
|
||||
const err = streamText(result.stderr)
|
||||
|
||||
@@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// Keep the exit marker last because parseExitStatus anchors there.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
||||
// Hint only when the composition exposes escalation, before the final exit marker.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
// A command may trap the termination and exit 0 after timeout; still report interruption.
|
||||
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
|
||||
if (result.signal !== null) {
|
||||
@@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @returns the delta text with any loss notice appended.
|
||||
* @param sandbox - settled sandbox facts, when this was a confined process.
|
||||
* @param escalationModes - escalation targets advertised by this composition.
|
||||
* @returns the delta text with any loss or sandbox notice appended.
|
||||
*/
|
||||
export function renderPwshProcessRead(read: BashProcessRead): string {
|
||||
export function renderPwshProcessRead(
|
||||
read: BashProcessRead,
|
||||
sandbox?: BashSandboxInfo,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const notices: string[] = []
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
|
||||
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
|
||||
}
|
||||
if (sandbox?.runnerFailed) {
|
||||
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
|
||||
} else if (sandbox?.denied) {
|
||||
notices.push(sandboxDenialMarker(sandbox.mode))
|
||||
if (escalationModes.length > 0) {
|
||||
notices.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) return read.delta
|
||||
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* text, truncation, timeout, abort, nonzero exits, background handles — so
|
||||
* these tests verify the schema, argument validation, workdir derivation,
|
||||
* managed `DSH_*` collection, abort translation, canonical result projection,
|
||||
* rendering, background task wiring, and the UI presenters. Real-pwsh behavior
|
||||
* sandbox denial rendering with the escalation surface, rendering,
|
||||
* background task wiring, and the UI presenters. Real-pwsh behavior
|
||||
* is pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -22,8 +23,11 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
|
||||
@@ -150,9 +154,106 @@ async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve
|
||||
* the calling session's standing policy and stamp it on the request, exactly
|
||||
* like the bash tool — the per-session sandbox-policy regression surface.
|
||||
* Records each confined mode and returns scriptable sandbox facts so the
|
||||
* escalation and rendering surfaces are testable without a real backend.
|
||||
*/
|
||||
class ConfiningFakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
modes: Array<string | undefined> = []
|
||||
|
||||
override get sandboxMode() {
|
||||
return 'read-only' as const
|
||||
}
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.modes.push(spec.sandboxPolicy?.mode)
|
||||
return runResult('ok\n', {
|
||||
sandbox: {
|
||||
mode: spec.sandboxPolicy?.mode ?? 'read-only',
|
||||
denied: false,
|
||||
...spec.command === 'without optional sandbox facts'
|
||||
? {}
|
||||
: { enforcement: 'full' as const, runnerFailed: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
this.modes.push(spec.sandboxPolicy?.mode)
|
||||
return fakeProcess()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */
|
||||
async function setupSandboxed(withApproval = false) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(ConfiningFakeBash)
|
||||
if (withApproval) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
const bash = ctx.bash as ConfiningFakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} whose session log carries the sandbox-policy
|
||||
* mode-override event the escalation flow evaluates against, with an
|
||||
* appendable log (the approval service records decisions through
|
||||
* `session.append`).
|
||||
*/
|
||||
function sandboxAgent(
|
||||
mode?: 'read-only' | 'workspace-write' | 'danger-full-access',
|
||||
ctx?: Context,
|
||||
onAppend?: (type: string) => void,
|
||||
): Agent {
|
||||
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
|
||||
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
|
||||
const id = SessionId('sandbox-session')
|
||||
return {
|
||||
id,
|
||||
...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
|
||||
session: {
|
||||
id,
|
||||
header: { version: 0, id, createdAt: 0 },
|
||||
events,
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
const event = { type, data }
|
||||
events.push(event)
|
||||
onAppend?.(type)
|
||||
return event
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} with the shared agent/session identity, give it a
|
||||
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
|
||||
* The fake session carries an empty event log (the sandbox-policy resolver
|
||||
* folds the log for mode overrides, mirroring a real session).
|
||||
*/
|
||||
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
@@ -160,7 +261,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
session: { id, header: { version: 0, id, createdAt: 0 }, events: [] },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
@@ -397,6 +498,203 @@ describe('execution through the bash seam', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandbox policy resolution', () => {
|
||||
it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => {
|
||||
const { ctx, bash } = await setupSandboxed()
|
||||
const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
|
||||
const agent = registerFakeAgent(ctx, 'policy-session')
|
||||
Object.assign(agent.session.header, { cwd: sessionCwd })
|
||||
const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
// The policy's workspace root is the session cwd canonicalized by the
|
||||
// policy service (realpath + resolve), NEVER the web server's launch dir;
|
||||
// the calling session's identity rides along for backend per-session state.
|
||||
expect(bash.requests[0]?.sandboxPolicy).toEqual({
|
||||
mode: 'read-only',
|
||||
workspaceRoot: resolvePath(realpathSync.native(sessionCwd)),
|
||||
sessionId: 'policy-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => {
|
||||
const { ctx, bash } = await setupSandboxed()
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
|
||||
expect(bash.requests[0]?.sandboxPolicy).toEqual({
|
||||
mode: 'read-only',
|
||||
workspaceRoot: resolvePath(realpathSync.native(process.cwd())),
|
||||
})
|
||||
|
||||
// The base FakeBash advertises no sandboxMode, so the tool must not stamp
|
||||
// any policy (the executor defaulting stays the executor's own).
|
||||
const plain = await setup()
|
||||
await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
|
||||
expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy')
|
||||
})
|
||||
|
||||
it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ConfiningFakeBash)
|
||||
await expect(ctx.plugin(ToolPwsh)).rejects.toThrow(
|
||||
'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox escalation through ctx.approval', () => {
|
||||
const escalate = {
|
||||
command: 'Write-Output ok',
|
||||
description: 'test escalation',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'the command needs workspace writes',
|
||||
}
|
||||
|
||||
it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
|
||||
const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
|
||||
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(schema.description).toContain('approval prompt')
|
||||
expect(schema.description).toContain('ConstrainedLanguage')
|
||||
expect(schema.description).toContain('named pipes')
|
||||
expect(schema.description).toContain('fails with EPERM')
|
||||
|
||||
for (const args of [
|
||||
{ command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' },
|
||||
{ command: 'Write-Output ok', description: 'd', justification: 'why' },
|
||||
{ command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
|
||||
]) {
|
||||
expect((await call(ctx, 'pwsh', args)).isError).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => {
|
||||
const { ctx } = await setup()
|
||||
const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
|
||||
expect(schema.description).not.toContain('ConstrainedLanguage')
|
||||
expect(schema.description).not.toContain('named pipes')
|
||||
expect(schema.description).not.toContain('sandbox_permissions')
|
||||
expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions')
|
||||
})
|
||||
|
||||
it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
|
||||
const plain = await setup()
|
||||
expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
|
||||
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
const prompted = vi.fn()
|
||||
ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
|
||||
const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
|
||||
expect(text(result)).toContain('not strictly wider')
|
||||
expect(prompted).not.toHaveBeenCalled()
|
||||
|
||||
const malformed = sandboxAgent()
|
||||
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
|
||||
type: 'sandbox/mode',
|
||||
data: { mode: 'unknown-mode' },
|
||||
})
|
||||
expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider')
|
||||
})
|
||||
|
||||
it('fails closed when approval cannot be routed', async () => {
|
||||
const withoutService = await setupSandboxed()
|
||||
expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service')
|
||||
|
||||
const withService = await setupSandboxed(true)
|
||||
expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route')
|
||||
expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rejected', 'user rejected'],
|
||||
['cancelled', 'was cancelled'],
|
||||
] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
|
||||
const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
|
||||
expect(text(result)).toContain(message)
|
||||
expect(bash.modes).toEqual([])
|
||||
})
|
||||
|
||||
it('runs a granted foreground or background call under the approved mode', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const agent = sandboxAgent(undefined, ctx)
|
||||
ctx.agents.register(agent)
|
||||
const foreground = await ctx.tools.execute({
|
||||
callId: CallId('sandbox-signal'),
|
||||
name: 'pwsh',
|
||||
arguments: escalate,
|
||||
agent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(foreground.isError).toBe(false)
|
||||
const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent)
|
||||
expect(text(background)).toBe('started background task pwsh-1')
|
||||
expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
|
||||
})
|
||||
|
||||
it('does not publish detached work when cancellation follows the escalation grant', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
const controller = new AbortController()
|
||||
const agent = sandboxAgent(undefined, ctx, (type) => {
|
||||
if (type === 'approval/decided') controller.abort()
|
||||
})
|
||||
ctx.agents.register(agent)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const start = vi.spyOn(bash, 'start')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('cancelled-escalation-background'),
|
||||
name: 'pwsh',
|
||||
arguments: { ...escalate, run_in_background: true },
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
expect(text(result)).toBe('Error: tool call aborted')
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the session override for ordinary calls and evaluates widening against it', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
const agent = sandboxAgent('workspace-write')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent)
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
|
||||
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
|
||||
})
|
||||
|
||||
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await call(ctx, 'pwsh', {
|
||||
command: 'without optional sandbox facts',
|
||||
description: 'exercise optional sandbox facts',
|
||||
})
|
||||
if (result.isError) throw new Error('expected foreground pwsh success')
|
||||
expect(result.value).toMatchObject({
|
||||
kind: 'foreground',
|
||||
sandbox: { mode: 'read-only', denied: false },
|
||||
})
|
||||
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
|
||||
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
|
||||
})
|
||||
|
||||
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
|
||||
const { ctx } = await setupSandboxed(true)
|
||||
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
|
||||
const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
|
||||
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background execution through the task runtime', () => {
|
||||
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
||||
const { ctx } = await setupWithTasks()
|
||||
@@ -641,6 +939,35 @@ describe('UI presentation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPwshResult sandbox markers', () => {
|
||||
const base = {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'out\n', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
|
||||
it('a denied run reports the denial marker before the exit marker', () => {
|
||||
expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } }))
|
||||
.toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]')
|
||||
})
|
||||
|
||||
it('hints only when the composition advertises escalation', () => {
|
||||
const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } }
|
||||
expect(renderPwshResult(denied, ['workspace-write'])).toBe(
|
||||
'out\n[sandbox: file access denied under read-only mode]\n'
|
||||
+ '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
|
||||
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]',
|
||||
)
|
||||
})
|
||||
|
||||
it('a confined run without a denial adds no sandbox marker', () => {
|
||||
expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPwshProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
@@ -677,6 +1004,20 @@ describe('renderPwshProcessRead', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('appends the runner-failed notice (denial outranked)', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true }))
|
||||
.toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]')
|
||||
})
|
||||
|
||||
it('appends the denial marker and hints only when escalation is advertised', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }))
|
||||
.toBe('x\n[sandbox: file access denied under read-only mode]')
|
||||
expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write']))
|
||||
.toBe('x\n[sandbox: file access denied under read-only mode]\n'
|
||||
+ '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
|
||||
+ '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOutcome', () => {
|
||||
|
||||
@@ -38,6 +38,18 @@
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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/bundle/base/README.md
|
||||
README.md: fb003908a262dc21edd3c9d49c972e487534f367
|
||||
README.zh.md: 13e64db6d34374fac63bf9bfd60544fc46b86f35
|
||||
README.md: 2a87b01ad4819750a58163f8c472e61ea633588e
|
||||
README.zh.md: dc79895355546812aa3371487190724f169c6260
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
|
||||
|
||||
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.
|
||||
|
||||
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
|
||||
|
||||
## Model Experience
|
||||
@@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer.
|
||||
- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`<temp>\dsh-<hash>`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
|
||||
|
||||
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。
|
||||
|
||||
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
|
||||
|
||||
## 模型体验
|
||||
@@ -17,3 +19,4 @@
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。
|
||||
- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`<temp>\dsh-<hash>`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
- id: agent
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
# The transport-independent default for Agents created by front doors.
|
||||
# The transport-independent default for Agents created by entry points.
|
||||
# Settings may supply a saved selection; consumers read it at creation time.
|
||||
- id: agent-default-model
|
||||
name: '@deepseek-ai/dsh-agent-default-model'
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -23,6 +24,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"windows.cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -46,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
@@ -57,6 +60,7 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
@@ -87,6 +91,7 @@
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
|
||||
@@ -13,13 +13,51 @@ import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
describe('dsh-base bundle', () => {
|
||||
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } }
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(resolve(root, 'package.json'), 'utf8'),
|
||||
) as { dsh?: { bundle?: { patch?: string } } }
|
||||
expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
|
||||
const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema })
|
||||
const parsed = yaml.load(
|
||||
readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
)
|
||||
expect(Array.isArray(parsed)).toBe(true)
|
||||
// The base layer is one insert list over the empty profile root.
|
||||
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? [])
|
||||
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(
|
||||
patch => patch.insert ?? [],
|
||||
)
|
||||
expect(rows.length).toBeGreaterThan(50)
|
||||
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
|
||||
})
|
||||
|
||||
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const parsed = yaml.load(
|
||||
readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
) as {
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
insert?: { id?: string; name?: string }[]
|
||||
config?: { policy?: string }
|
||||
}[]
|
||||
const disables = parsed
|
||||
.filter(patch => patch.disabled === true)
|
||||
.map(patch => patch.id)
|
||||
// Only the POSIX bash stack is disabled: the Windows roster confines the
|
||||
// pwsh executor through the ACL runner chain, so the sandbox/policy rows,
|
||||
// the permission switcher, fs-sandbox, and the approval service all stay
|
||||
// enabled exactly as on POSIX — only the shell is swapped.
|
||||
expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
|
||||
const inserted = parsed
|
||||
.flatMap(patch => patch.insert ?? [])
|
||||
.map(row => row.id)
|
||||
expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
|
||||
// The patch no longer touches the permission/approval surface at all.
|
||||
expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
31
packages/bundle/base/windows.cordis.patch.yml
Normal file
31
packages/bundle/base/windows.cordis.patch.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
# The dsh-base Windows platform layer: applied by the dsh launcher on win32
|
||||
# hosts, between the bundle layers and the user layers. Windows confines
|
||||
# through the ACL restricted-token runner (the win32 chain of
|
||||
# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
|
||||
# stack is the SANDBOXED PowerShell executor plus the full permission
|
||||
# surface: sandbox/sandbox-policy enforce the file-effect policy, the
|
||||
# permission switcher and the approval service run exactly as on POSIX, and
|
||||
# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
|
||||
# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
|
||||
# Only the POSIX bash
|
||||
# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
|
||||
# A Windows host that prefers the unconfined local pwsh executor or full
|
||||
# access overrides these rows through its profile or home cordis.patch.yml.
|
||||
# The bash-restore recipe must be complete: disable pwsh-sandbox and
|
||||
# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
|
||||
# families register the same 'bash' service, so re-enabling the bash rows
|
||||
# while pwsh-sandbox stays inserted fails loud at load on a duplicate
|
||||
# registration.
|
||||
|
||||
- id: bash-sandbox
|
||||
disabled: true
|
||||
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
@@ -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.
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 0a7d9975093da558af623ee9940f4be398526821
|
||||
README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a
|
||||
README.md: 753d1de796ba8ff20217d423555710429e9b7a75
|
||||
README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -181,6 +181,18 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'models/changed'(): void
|
||||
/**
|
||||
* One session's agent preset changed (host/session-preset-changed
|
||||
* passthrough), so everything its composition decides — the command
|
||||
* catalog, the skill catalog — is stale for that session and no other.
|
||||
* Every connected client observes it, not only the one that issued the
|
||||
* switch. Subscribers refetch their own session-keyed caches; the frame
|
||||
* carries no catalog.
|
||||
* @mode emit
|
||||
* @param sessionId - the session whose composition changed.
|
||||
* @param agentPreset - the preset it now runs.
|
||||
*/
|
||||
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
|
||||
/**
|
||||
* A connection generation was (re-)established. Wire-derived caches must
|
||||
* treat their state as stale and repull (commands directory; the queue
|
||||
@@ -244,6 +256,9 @@ export function apply(ctx: Context): void {
|
||||
// and model surfaces) subscribe on ctx.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
|
||||
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
|
||||
@@ -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) {
|
||||
@@ -767,6 +780,14 @@ export class SessionManager {
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-preset-changed': {
|
||||
// Every connected client observes the switch here; only the tab that
|
||||
// issued it also gets the RPC echo. The merge keeps the row's own
|
||||
// updatedAt and lowers `blank` only, so re-applying the switching
|
||||
// tab's own frame is a no-op.
|
||||
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
|
||||
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
|
||||
@@ -992,7 +1013,7 @@ export class SessionManager {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.pendingInteraction === entry.pendingInteraction
|
||||
@@ -1040,9 +1061,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)),
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type FeedRow = {
|
||||
origin?: 'subagent'
|
||||
running?: boolean
|
||||
blank?: boolean
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
...(r.origin !== undefined ? { origin: r.origin } : {}),
|
||||
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
await b.svc.refresh()
|
||||
@@ -70,6 +72,38 @@ describe('list store projection', () => {
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
|
||||
|
||||
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
|
||||
// title, running, and blank bits, so an identity guard blind to the preset
|
||||
// would serve the old row forever — and every reader (the hero chip's own
|
||||
// no-op check, the header label) would keep the composition it replaced.
|
||||
b.svc.noteAgentPreset(sid('s1'), 'minimal')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
|
||||
// Every connected client gets this frame; only the switching tab gets the
|
||||
// RPC echo. A client that ignored the payload would keep labelling the
|
||||
// session with the composition it replaced.
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* → ctx 'commands/changed'; host/session-preset-changed →
|
||||
* ctx 'session/preset-changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
|
||||
})
|
||||
expect(seen).toEqual([['s1', 'minimal']])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
@@ -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'])
|
||||
@@ -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-command/README.md
|
||||
README.md: bc7386c8fca3b5c623473328bee6322fa7295277
|
||||
README.zh.md: 54190ac9144b1bfc12ba84a47474311d5a5391ea
|
||||
README.md: db785e769cb40235a77d05b4b66d096896a35d8a
|
||||
README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a
|
||||
|
||||
@@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach
|
||||
|
||||
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
|
||||
|
||||
|
||||
@@ -124,6 +124,11 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
|
||||
// A preset switch changes which commands one session's agent resolves and
|
||||
// registers nothing globally, so the registry-wide signal above never
|
||||
// fires for it: repull that key alone, soft, so the old snapshot serves
|
||||
// the menu until the new one lands.
|
||||
ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) })
|
||||
ctx.on('connection/reset', () => { this.directory.resetConnected() })
|
||||
}
|
||||
|
||||
|
||||
@@ -617,6 +617,30 @@ describe('directory invalidation events', () => {
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('session/preset-changed repulls the recomposed session and leaves the others served', async () => {
|
||||
const rounds = new Map<SessionId, number>()
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: (payload) => {
|
||||
const round = (rounds.get(payload.sessionId) ?? 0) + 1
|
||||
rounds.set(payload.sessionId, round)
|
||||
return Promise.resolve({
|
||||
commands: round === 1
|
||||
? S1_CMDS
|
||||
: [{ name: 'fresh', description: '', input: { hint: 'h' } }],
|
||||
})
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
await warm(proj('s2'))
|
||||
// A preset switch changes which commands one session's agent resolves;
|
||||
// every other session keeps the catalog its own composition serves.
|
||||
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('connection/reset hard-drops every session key until its rewarm lands', async () => {
|
||||
let block = false
|
||||
let release!: (value: { commands: CommandDescriptor[] }) => void
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: b57f88b5a030a6c20c957e26ea32fb125f106ab4
|
||||
README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7
|
||||
README.md: f684f99c9e80a02e3ccad57c9a4d7246df0b192b
|
||||
README.zh.md: 61c746e35d3a221d34cf9e830a63ce5d8fa0dbfd
|
||||
|
||||
@@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
|
||||
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// The per-session input machine registry (InputService face; published as
|
||||
// ctx.conversation.input by the service below sharing this one instance).
|
||||
const inputHub = new InputHub(ctx)
|
||||
const inputHub = new InputHub(ctx, t)
|
||||
|
||||
// The composer-block registry: a plugin that knows a session cannot send —
|
||||
// ui-model, when no adapter serves the session's route — raises a block
|
||||
@@ -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) },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user