Merge remote-tracking branch 'origin/master' into fix/continuable-subagent-policy-inheritance
# Conflicts: # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent/package.json # packages/subagent/subagent/src/child-agent.ts # packages/subagent/subagent/src/continuation.ts # packages/subagent/subagent/tsconfig.json
This commit is contained in:
@@ -26,13 +26,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -51,14 +51,13 @@ export interface DshProfileManifest {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `dsh`-owned manifest section of a package.json. The nested key names
|
||||
* the manifest kind: a bundle package declares `bundle`, a profile directory
|
||||
* declares `profile`; nothing declares both.
|
||||
* The profile-launcher slice of the `dsh`-owned package.json section. A
|
||||
* manifest may declare both roles; other consumers own additional keys.
|
||||
*/
|
||||
export interface DshManifestSection {
|
||||
/** Present on bundle packages only. */
|
||||
/** Bundle metadata consumed by the profile launcher. */
|
||||
bundle?: DshBundleManifest
|
||||
/** Present on profile manifests only. */
|
||||
/** Profile metadata consumed by the profile launcher. */
|
||||
profile?: DshProfileManifest
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
# `dsh.client` rows are the browser roster the modules node half scans into
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
- id: code-runtime
|
||||
@@ -101,9 +101,9 @@
|
||||
printUrl: true
|
||||
surfaceContext: true
|
||||
|
||||
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
|
||||
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
|
||||
|
||||
# Dual-face: node half scans this very tree for dshClient rows, composes
|
||||
# Dual-face: node half scans this very tree for dsh.client rows, composes
|
||||
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
|
||||
# module table the shell kernel constructs before cordis exists (adopted
|
||||
# as a plugin entry by the kernel, never fetched).
|
||||
|
||||
@@ -91,9 +91,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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/modules/README.md
|
||||
README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace
|
||||
README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f
|
||||
README.md: a1d578850c2518a85dc32f048768b78caf5ffec4
|
||||
README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0
|
||||
|
||||
@@ -8,7 +8,7 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -22,10 +22,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -44,7 +44,7 @@ declare module 'cordis' {
|
||||
* One composed client entry pushed by the host (a graph row). Wire
|
||||
* single source: the host node half (package root) produces this same shape.
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
* metadata (the authoritative edges live in each package's `dsh.client`
|
||||
* declaration and reach fibers through entry creation).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* Node half of the client module system (`dsh.client` dual-face package): scans
|
||||
* the host Loader's entries for packages declaring `dsh.client`, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
|
||||
* map, taps the index render to inject the boot manifest, and provides the
|
||||
@@ -43,7 +43,7 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration fields, validated one by one after reading the file. */
|
||||
/** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
@@ -51,7 +51,7 @@ interface DshClientDeclaration {
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
|
||||
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
|
||||
interface PkgMeta {
|
||||
clientPath: string
|
||||
inject?: string[]
|
||||
@@ -105,21 +105,21 @@ interface WebPluginRecord {
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
|
||||
}
|
||||
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`)
|
||||
}
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
@@ -175,7 +175,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The web plugin table service: incremental dshClient scan + wire composition
|
||||
* The web plugin table service: incremental `dsh.client` scan + wire composition
|
||||
* + bundle route + index tap. Construction runs the activation scan
|
||||
* synchronously — a malformed declaration or missing bundle among the
|
||||
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
|
||||
@@ -186,7 +186,7 @@ export class ClientModuleHostService extends Service {
|
||||
|
||||
private readonly table = new Map<string, WebPluginRecord>()
|
||||
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
|
||||
// subpath rows — or a package without a web dshClient declaration) are
|
||||
// subpath rows — or a package without a web `dsh.client` declaration) are
|
||||
// cached as null and never expire: plugin-set changes take effect on restart.
|
||||
private readonly pkgMeta = new Map<string, PkgMeta | null>()
|
||||
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
|
||||
@@ -342,14 +342,18 @@ export class ClientModuleHostService extends Service {
|
||||
return null
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const decl = parseDshClient(pkgName, pkg.dshClient)
|
||||
const dsh = pkg.dsh
|
||||
const decl = parseDshClient(
|
||||
pkgName,
|
||||
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
|
||||
)
|
||||
if (decl === undefined || decl.platform !== 'web') {
|
||||
this.pkgMeta.set(pkgName, null)
|
||||
return null
|
||||
}
|
||||
const clientRel = clientExportOf(pkgName, pkg.exports)
|
||||
if (clientRel === undefined) {
|
||||
throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
|
||||
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
|
||||
@@ -17,8 +17,11 @@ afterEach(() => {
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Create a resolvable dshClient package whose client export points at the returned path. */
|
||||
function writePackage(packageName: string): string {
|
||||
/** Create a resolvable package whose client export points at the returned path. */
|
||||
function writePackage(
|
||||
packageName: string,
|
||||
metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
|
||||
): string {
|
||||
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
|
||||
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
|
||||
const clientPath = join(pkgRoot, 'lib', 'client.js')
|
||||
@@ -29,7 +32,7 @@ function writePackage(packageName: string): string {
|
||||
'./client': './lib/client.js',
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
dshClient: { platform: 'web' },
|
||||
...metadata,
|
||||
}))
|
||||
return clientPath
|
||||
}
|
||||
@@ -66,6 +69,20 @@ function construct(packageNames: string[]): ClientModuleHostService {
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
it('allows sibling dsh roles', () => {
|
||||
const currentName = '@fixture/current-client-field'
|
||||
const clientPath = writePackage(currentName, {
|
||||
dsh: {
|
||||
bundle: { patch: './cordis.patch.yml' },
|
||||
client: { platform: 'web' },
|
||||
profile: { bundles: [] },
|
||||
},
|
||||
})
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
|
||||
})
|
||||
|
||||
it('groups missing bundles under one source-build instruction with a package/path list', () => {
|
||||
const firstName = '@fixture/missing-first'
|
||||
const secondName = '@fixture/missing-second'
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
|
||||
@@ -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/test-runtime/README.md
|
||||
README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
|
||||
README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310
|
||||
README.md: d5c0797c37168578f08a08f3d5d57670d7973db0
|
||||
README.zh.md: 57854213c3a9ea28850665eb26d07bc824c017e8
|
||||
|
||||
@@ -8,7 +8,7 @@ The doubles implement the same outward faces features receive through ctx (`Test
|
||||
|
||||
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
|
||||
|
||||
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
|
||||
Not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
|
||||
|
||||
不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。
|
||||
不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* declaration, registration, scope, store, inject, rendering, updates, and
|
||||
* disposal without hand-building the machinery per suite.
|
||||
*
|
||||
* Not part of the product plugin graph (no `dshClient`); feature packages
|
||||
* Not part of the product plugin graph (no `dsh.client`); feature packages
|
||||
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
|
||||
* machinery — everything mounts the production implementations.
|
||||
* @module @deepseek-ai/dsh-client-test-runtime
|
||||
|
||||
@@ -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-agent-preset/README.md
|
||||
README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c
|
||||
README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd
|
||||
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
|
||||
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
|
||||
|
||||
@@ -26,6 +26,8 @@ Options and the current default both come from one `agentPreset.list` call. The
|
||||
|
||||
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.
|
||||
|
||||
Preset files publish one unlocalized `name` and `description`, which Web uses for every `user` row and unknown `system` row. For the four shipped ids (`standard`, `code`, `minimal`, and `cordis`), Web resolves both fields from its active locale only when the roster marks the row `system`; an identically named `user` preset keeps its file metadata.
|
||||
|
||||
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
|
||||
|
||||
@@ -26,6 +26,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
|
||||
|
||||
本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。
|
||||
|
||||
preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其用于所有 `user` 行和未知的 `system` 行。对于四个随附 id(`standard`、`code`、`minimal` 与 `cordis`),只有名单将该行标记为 `system` 时,Web 才会从当前 locale 解析这两个字段;同名的 `user` preset 仍使用其文件元数据。
|
||||
|
||||
本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。
|
||||
|
||||
## 管理分区
|
||||
|
||||
@@ -22,15 +22,17 @@
|
||||
"./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"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"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",
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 { presetDisplayText } from './locales.ts'
|
||||
import css from './AgentPresetLabel.module.css'
|
||||
|
||||
/** Registration-side business face for the header label. */
|
||||
@@ -53,10 +54,11 @@ export function AgentPresetLabel({
|
||||
if (preset === undefined) return null
|
||||
|
||||
const option = options.find(entry => entry.id === preset)
|
||||
const text = option === undefined ? undefined : presetDisplayText(option, t)
|
||||
return (
|
||||
<span className={css.label} title={option?.description ?? t('headerHint')}>
|
||||
<span className={css.label} title={text?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
{option?.name ?? preset}
|
||||
{text?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
|
||||
import { PresetMenu } from './PresetMenu.tsx'
|
||||
import css from './AgentPresetRow.module.css'
|
||||
|
||||
@@ -52,11 +52,11 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR
|
||||
// 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.
|
||||
// Every preset surface applies the same display-copy rule. The id remains
|
||||
// addressing rather than a label, except where no display name exists.
|
||||
const chosen = state.options.find(option => option.id === state.currentValue)
|
||||
const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
const label = state.currentValue === '' ? t('loading') : (chosenText?.name ?? state.currentValue)
|
||||
const description: string = state.error ?? t('description')
|
||||
|
||||
return (
|
||||
@@ -69,7 +69,7 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR
|
||||
options={state.options}
|
||||
selectedId={state.currentValue}
|
||||
label={label}
|
||||
userTrustLabel={t('userTrust')}
|
||||
t={t}
|
||||
buttonClassName={css.selector}
|
||||
chevronClassName={css.chevron}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai
|
||||
// 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 { presetDisplayText } from './locales.ts'
|
||||
import css from './AgentPresetSeat.module.css'
|
||||
|
||||
/** Registration-side business face for the hero chip. */
|
||||
@@ -57,22 +58,26 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
if (state.options.length === 0 || state.current === '') return null
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
|
||||
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>
|
||||
),
|
||||
}))}
|
||||
items={state.options.map((option) => {
|
||||
const text = presetDisplayText(option, t)
|
||||
return {
|
||||
id: option.id,
|
||||
// Name and description together: the id alone never says what a
|
||||
// preset does, which is why the roster carries display copy.
|
||||
label: (
|
||||
<span className={css.item}>
|
||||
<span className={css.itemName}>{text.name}</span>
|
||||
<span className={css.itemDesc}>{text.description ?? t('noDescription')}</span>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
@@ -91,7 +96,7 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosen?.name ?? state.current}
|
||||
{chosenText?.name ?? state.current}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
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 { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
/** Registration-side business face for the management section. */
|
||||
@@ -77,11 +77,13 @@ 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))
|
||||
const source = draft === null ? undefined : state.rows.find(row => row.id === draft.from)
|
||||
const sourceTitle = source === undefined ? draft?.fromTitle : presetDisplayText(source, t).name
|
||||
return (
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
onClose={() => { actions.cancelCopy() }}
|
||||
title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`}
|
||||
title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${sourceTitle}`}
|
||||
closeLabel={t('close')}
|
||||
description={t('copyIntro')}
|
||||
className={css.dialog as string}
|
||||
@@ -143,6 +145,11 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
|
||||
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const { useAgentPresetSection, t, load } = props
|
||||
const state = useAgentPresetSection(snapshot => snapshot)
|
||||
const viewedId = state.view?.id
|
||||
const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId)
|
||||
const viewedTitle = state.view === null
|
||||
? ''
|
||||
: viewedRow === undefined ? state.view.title : presetDisplayText(viewedRow, t).name
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -170,13 +177,15 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<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)
|
||||
const group = state.rows
|
||||
.filter(row => row.trust === trust)
|
||||
.map(row => ({ row, text: presetDisplayText(row, t) }))
|
||||
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 => (
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
@@ -196,12 +205,12 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
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}`}
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
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>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
@@ -210,7 +219,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
@@ -231,7 +240,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${row.name ?? row.id}`}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
@@ -243,7 +252,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
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}`}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconFolderOpenOutline16 />
|
||||
@@ -256,7 +265,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
@@ -267,7 +276,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${row.name ?? row.id}`}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
@@ -325,7 +334,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<Modal
|
||||
open={state.view !== null}
|
||||
onClose={() => { props.closeView() }}
|
||||
title={state.view === null ? '' : `${t('view')} · ${state.view.title}`}
|
||||
title={state.view === null ? '' : `${t('view')} · ${viewedTitle}`}
|
||||
closeLabel={t('close')}
|
||||
description={t('composition')}
|
||||
className={css.dialog as string}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
|
||||
|
||||
/** What one surface passes to the shared picker. */
|
||||
export interface PresetMenuProps {
|
||||
@@ -20,8 +21,8 @@ export interface PresetMenuProps {
|
||||
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
|
||||
/** Active Web locale lookup. */
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
/** Class for the trigger button, owned by the calling surface. */
|
||||
buttonClassName: string | undefined
|
||||
/** Class for the chevron, owned by the calling surface. */
|
||||
@@ -42,22 +43,22 @@ export interface PresetMenuProps {
|
||||
* @returns the menu and its trigger.
|
||||
*/
|
||||
export function PresetMenu({
|
||||
options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName,
|
||||
options, selectedId, label, t, 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,
|
||||
}))}
|
||||
items={options.map((option) => {
|
||||
const name = presetDisplayText(option, t).name
|
||||
return {
|
||||
id: option.id,
|
||||
// All preset surfaces resolve copy the same way; the id is addressing,
|
||||
// not a label, except where no display name exists.
|
||||
label: option.trust === 'user' ? `${name} · ${t('userTrust')}` : name,
|
||||
}
|
||||
})}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
onOpenChange(false)
|
||||
|
||||
@@ -157,7 +157,8 @@ export function apply(ctx: ClientContext): void {
|
||||
const label = scope.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'agent-preset',
|
||||
order: 20,
|
||||
// Static session context occupies the header's leading negative-order band.
|
||||
order: -10,
|
||||
locale: 'settings.agentPreset',
|
||||
inject: labelInjected,
|
||||
}, AgentPresetLabel)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view'
|
||||
| 'presetStandardName' | 'presetStandardDescription'
|
||||
| 'presetCodeName' | 'presetCodeDescription'
|
||||
| 'presetMinimalName' | 'presetMinimalDescription'
|
||||
| 'presetCordisName' | 'presetCordisDescription'
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
@@ -30,6 +34,18 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
builtIn: 'Built-in',
|
||||
setDefault: 'Set as default',
|
||||
view: 'View',
|
||||
presetStandardName: 'Standard mode',
|
||||
presetStandardDescription:
|
||||
'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.',
|
||||
presetCodeName: 'Code mode',
|
||||
presetCodeDescription:
|
||||
'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.',
|
||||
presetMinimalName: 'Minimal mode',
|
||||
presetMinimalDescription:
|
||||
'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.',
|
||||
presetCordisName: 'Creator mode',
|
||||
presetCordisDescription:
|
||||
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||
delete: 'Delete',
|
||||
@@ -82,6 +98,14 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
builtIn: '内置',
|
||||
setDefault: '设为默认',
|
||||
view: '查看',
|
||||
presetStandardName: '标准模式',
|
||||
presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。',
|
||||
presetCodeName: '代码模式',
|
||||
presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。',
|
||||
presetMinimalName: '极简模式',
|
||||
presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。',
|
||||
presetCordisName: '创造模式',
|
||||
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
|
||||
duplicate: '复制',
|
||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||
delete: '删除',
|
||||
@@ -116,3 +140,53 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
}
|
||||
|
||||
/** Preset roster fields needed to resolve Web display copy. */
|
||||
export interface PresetDisplaySource {
|
||||
/** Stable preset id. */
|
||||
readonly id: string
|
||||
/** Whether the deployment ships the preset or the user owns it. */
|
||||
readonly trust: 'system' | 'user'
|
||||
/** Unlocalized name published by the preset. */
|
||||
readonly name?: string
|
||||
/** Unlocalized description published by the preset. */
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
/** Display copy resolved for the active Web locale. */
|
||||
export interface PresetDisplayText {
|
||||
/** Localized built-in name or the preset's own fallback name. */
|
||||
readonly name: string
|
||||
/** Localized built-in description or the preset's own description. */
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
interface PresetLocaleKeys {
|
||||
readonly name: AgentPresetSettingsKey
|
||||
readonly description: AgentPresetSettingsKey
|
||||
}
|
||||
|
||||
const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>> = {
|
||||
standard: { name: 'presetStandardName', description: 'presetStandardDescription' },
|
||||
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
|
||||
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
|
||||
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve preset display copy without making user-authored metadata translatable.
|
||||
* @param preset - roster row whose copy is being rendered.
|
||||
* @param t - active Web locale lookup.
|
||||
* @returns localized copy for a known shipped preset, otherwise file metadata.
|
||||
*/
|
||||
export function presetDisplayText(
|
||||
preset: PresetDisplaySource,
|
||||
t: (key: AgentPresetSettingsKey) => string,
|
||||
): PresetDisplayText {
|
||||
const keys = preset.trust === 'system' ? BUILT_IN_PRESET_KEYS[preset.id] : undefined
|
||||
if (keys !== undefined) return { name: t(keys.name), description: t(keys.description) }
|
||||
return {
|
||||
name: preset.name ?? preset.id,
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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.
|
||||
* package.json dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('ui-agent-preset apply', () => {
|
||||
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 })
|
||||
expect(label.options).toMatchObject({ id: 'agent-preset', order: -10 })
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.session.header.actions')).toHaveLength(0)
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('the General-settings row', () => {
|
||||
const actions = renderRow()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
expect(screen.getByRole('button').textContent).toContain(en.presetStandardName)
|
||||
})
|
||||
|
||||
it('marks a locally authored option as local', () => {
|
||||
@@ -102,7 +102,7 @@ describe('the General-settings row', () => {
|
||||
// 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)
|
||||
expect(screen.getAllByText(en.presetStandardName)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('falls back to the id for a preset that published no name', () => {
|
||||
@@ -128,6 +128,12 @@ describe('the General-settings row', () => {
|
||||
expect(screen.getByText('bare')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the selected id until a stale roster contains it', () => {
|
||||
renderRow({ currentValue: 'arriving', options: [] })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain('arriving')
|
||||
})
|
||||
|
||||
it('writes the picked preset and closes the menu', () => {
|
||||
const actions = renderRow()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
@@ -194,7 +200,7 @@ describe('the new-session chip', () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
expect(screen.getByRole('button').textContent).toContain(en.presetStandardName)
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
@@ -205,7 +211,7 @@ describe('the new-session chip', () => {
|
||||
|
||||
// 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()
|
||||
expect(screen.getByText(en.presetStandardDescription)).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()
|
||||
@@ -218,6 +224,12 @@ describe('the new-session chip', () => {
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
})
|
||||
|
||||
it('shows the staged id until a stale roster contains it', () => {
|
||||
renderSeat({ current: 'arriving' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain('arriving')
|
||||
})
|
||||
|
||||
it('stages the picked preset and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
@@ -267,7 +279,7 @@ describe('the session-header label', () => {
|
||||
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('标准模式')
|
||||
expect(screen.getByTitle(en.presetStandardDescription).textContent).toBe(en.presetStandardName)
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
|
||||
33
packages/client/ui-agent-preset/tests/locales.spec.ts
Normal file
33
packages/client/ui-agent-preset/tests/locales.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Web-localized copy for the four shipped presets and file copy for every other row. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { en, presetDisplayText, zh } from '../src/client/locales.ts'
|
||||
|
||||
const translate = (bundle: typeof en) => (key: keyof typeof en): string => bundle[key]
|
||||
|
||||
describe('preset display copy', () => {
|
||||
it.each([
|
||||
['standard', 'presetStandardName', 'presetStandardDescription'],
|
||||
['code', 'presetCodeName', 'presetCodeDescription'],
|
||||
['minimal', 'presetMinimalName', 'presetMinimalDescription'],
|
||||
['cordis', 'presetCordisName', 'presetCordisDescription'],
|
||||
] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => {
|
||||
const preset = { id, trust: 'system' as const, name: 'file name', description: 'file description' }
|
||||
|
||||
expect(presetDisplayText(preset, translate(en)))
|
||||
.toEqual({ name: en[nameKey], description: en[descriptionKey] })
|
||||
expect(presetDisplayText(preset, translate(zh)))
|
||||
.toEqual({ name: zh[nameKey], description: zh[descriptionKey] })
|
||||
})
|
||||
|
||||
it('keeps file metadata for user and unknown system presets', () => {
|
||||
const fileCopy = { name: '我的标准', description: '团队自己的 preset。' }
|
||||
|
||||
expect(presetDisplayText({ id: 'standard', trust: 'user', ...fileCopy }, translate(en)))
|
||||
.toEqual(fileCopy)
|
||||
expect(presetDisplayText({ id: 'deployment-extra', trust: 'system', ...fileCopy }, translate(en)))
|
||||
.toEqual(fileCopy)
|
||||
expect(presetDisplayText({ id: 'bare', trust: 'user' }, translate(en)))
|
||||
.toEqual({ name: 'bare' })
|
||||
})
|
||||
})
|
||||
@@ -85,13 +85,13 @@ describe('the preset list', () => {
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('shows the published name and description, falling back to the id', () => {
|
||||
it('shows resolved copy for built-ins and falls back to custom ids', () => {
|
||||
renderSection()
|
||||
|
||||
// The name is what a picker reads; the id stays visible as the key the
|
||||
// Display copy 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()
|
||||
expect(screen.getByText(en.presetStandardName)).toBeTruthy()
|
||||
expect(screen.getByText(en.presetStandardDescription)).toBeTruthy()
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0)
|
||||
expect(within(mine).getByText(en.noDescription)).toBeTruthy()
|
||||
@@ -134,7 +134,7 @@ describe('the preset list', () => {
|
||||
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}: 标准模式` })
|
||||
const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: ${en.presetStandardName}` })
|
||||
expect(inUse).toHaveProperty('disabled', true)
|
||||
fireEvent.click(inUse)
|
||||
|
||||
@@ -150,8 +150,8 @@ describe('the preset list', () => {
|
||||
// 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()
|
||||
expect(within(standard).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeTruthy()
|
||||
expect(within(standard).queryByRole('button', { name: `${en.openLocation}: ${en.presetStandardName}` })).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()
|
||||
@@ -161,13 +161,13 @@ describe('the preset list', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy()
|
||||
expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull()
|
||||
expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: ${en.presetStandardName}` })).toBeNull()
|
||||
})
|
||||
|
||||
it('disables duplication when nothing is writable, and says why', () => {
|
||||
renderSection({ authorable: false })
|
||||
|
||||
const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` })
|
||||
const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: ${en.presetStandardName}` })
|
||||
expect(duplicate).toHaveProperty('disabled', true)
|
||||
expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
|
||||
})
|
||||
@@ -205,7 +205,7 @@ describe('the preset list', () => {
|
||||
// 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).queryByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeNull()
|
||||
expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML')
|
||||
})
|
||||
|
||||
@@ -232,7 +232,7 @@ describe('the preset list', () => {
|
||||
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}: 标准模式` }))
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` }))
|
||||
|
||||
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
|
||||
expect(actions.openLocation).toHaveBeenCalledWith('mine')
|
||||
@@ -311,7 +311,7 @@ describe('the copy dialog', () => {
|
||||
const actions = renderSection({ copy: draft })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`)
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} ${en.presetStandardName}`)
|
||||
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: '我的模式' } })
|
||||
@@ -374,11 +374,17 @@ describe('the read-only viewer', () => {
|
||||
renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`)
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · ${en.presetStandardName}`)
|
||||
expect(within(dialog).getByText(en.composition)).toBeTruthy()
|
||||
expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n')
|
||||
})
|
||||
|
||||
it('keeps the loaded title when the viewed row leaves the roster', () => {
|
||||
renderSection({ view: { id: 'retired', title: 'Retired mode', content: '- id: tool-bash\n' } })
|
||||
|
||||
expect(screen.getByRole('dialog').getAttribute('aria-label')).toBe(`${en.view} · Retired mode`)
|
||||
})
|
||||
|
||||
it('closes through the controller', () => {
|
||||
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Command UI plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration. The host command registry itself mounts separately
|
||||
* (bootHost + CommandService).
|
||||
*/
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./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-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -38,7 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.session': { kind: 'single'; scope: 'session' }
|
||||
/** Strict-session header above the resident conversation scrollport. */
|
||||
'conversation.session.header': { kind: 'single'; scope: 'session' }
|
||||
/** Session-header actions contributed by feature plugins. */
|
||||
/**
|
||||
* Session-header actions contributed by feature plugins. Entries render
|
||||
* by ascending `order`; negative values are reserved for static session
|
||||
* context that precedes interactive actions.
|
||||
*/
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Deliverables plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration.
|
||||
*/
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Goal surface plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./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-command"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Permission surfaces plugin, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* new-session Settings row and current-session command picker through
|
||||
* exports["./client"], discovered from the package.json dshClient declaration.
|
||||
* exports["./client"], discovered from the package.json dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Plan control plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration. Plan behavior itself (the /plan command, the plan projection
|
||||
* unit, the policy section) is owned by `@deepseek-ai/dsh-plan-mode`,
|
||||
* composed independently on the host roster.
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -27,7 +27,7 @@ export type {
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* ui-sidebar's apply, whose activation order relative to this one is NOT
|
||||
* constrained (dshClient.inject edges are informational); registration
|
||||
* constrained (dsh.client.inject edges are informational); registration
|
||||
* depends on the slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots']
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Skill reference plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Slash trigger plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half ships
|
||||
* via exports["./client"], discovered through the package.json dshClient
|
||||
* via exports["./client"], discovered through the package.json dsh.client
|
||||
* declaration.
|
||||
*/
|
||||
|
||||
|
||||
@@ -22,15 +22,17 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-slash"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Subagent reference plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
|
||||
@@ -23,14 +23,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,12 +22,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -22,14 +22,16 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -37,7 +37,7 @@ const NS = 'workspace'
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* to this one is NOT constrained: dsh.client.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore depends on each slot
|
||||
* declaration through `slots.inject()` instead of assuming order.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader (load and lifecycle
|
||||
* follow the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration).
|
||||
* through the package.json dsh.client declaration).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the workspace picker plugin. */
|
||||
|
||||
@@ -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/context/workspace-context/README.md
|
||||
README.md: 7ab21bbf8c72f8424bc8d4fdad9153c7ed8bb7e9
|
||||
README.zh.md: eb807bfc0611854d54eda3ff26c97c6af51da529
|
||||
README.md: 1ae47bef728a81c61f0db637872c93321a7bc687
|
||||
README.zh.md: 8087412057b3e5924764df179ae293db5699f9ae
|
||||
|
||||
@@ -50,7 +50,7 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
|
||||
|
||||
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it.
|
||||
|
||||
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. At the first pre-step after resume or hot remount, a compatible visible baseline is retained and compared with the files retained by the current complete rendering. Unchanged and budget-omitted files append nothing; offline additions, edits, removals, and files leaving the retained budget set append `set`, `replace`, or `remove` transitions. An incompatible visible baseline is superseded by one complete current baseline, including an explicit empty baseline when no candidate remains. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a resumed session reconciles its baseline, or when an entering pre-step restores a shadowed baseline.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;若被拒绝,当前上下文则继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch;串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
|
||||
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表,仍可发布字节预算诊断。动态批次若没有可提交变更,则完全不注入,并在后续 touch 时重试。
|
||||
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。恢复或插件热重挂后的第一次 pre-step 会保留兼容的可见基线,并将它与当前完整渲染所保留的文件进行比较。未变化和被预算省略的文件不追加任何内容;agent 离线期间新增、编辑、移除或不再属于预算保留集的文件会追加 `set`、`replace` 或 `remove` 转换。不兼容的可见基线会被一条完整的当前基线取代;如果没有候选文件,这条当前基线会是显式空基线。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复后的会话对账其基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { trimmedInstructionDigest } from './digest.ts'
|
||||
import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
|
||||
import {
|
||||
decodeScopeKey,
|
||||
renderWorkspaceInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
} from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
export interface InstructionFile {
|
||||
@@ -64,7 +70,6 @@ export interface RenderedInstructionSet {
|
||||
/** Candidates retained by content deduplication and byte budgeting. */
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
@@ -420,26 +425,26 @@ export async function loadBaselineInstructionSet(
|
||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||
if (deduped.length === 0) {
|
||||
if (options.replacePreviousBaseline !== true) return undefined
|
||||
const { rendered, included } = renderWorkspaceInstructionSet([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
})
|
||||
return {
|
||||
rendered: renderWorkspaceContext([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
}),
|
||||
rendered,
|
||||
observed: [],
|
||||
included: [],
|
||||
included,
|
||||
}
|
||||
}
|
||||
const rendered = renderWorkspaceContext(deduped, {
|
||||
const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
|
||||
maxBytes: config.maxBytes,
|
||||
...options.replacePreviousBaseline === undefined
|
||||
? {}
|
||||
: { replacePreviousBaseline: options.replacePreviousBaseline },
|
||||
})
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
return {
|
||||
rendered,
|
||||
observed: loaded,
|
||||
included: deduped.filter(file => !omitted.has(file.absolutePath)),
|
||||
included,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ export interface RenderedWorkspaceContext {
|
||||
truncated: TruncatedInstruction[]
|
||||
}
|
||||
|
||||
interface RenderedInstructionContext extends RenderedWorkspaceContext {
|
||||
/**
|
||||
* Original files semantically represented by rendered section text. This is
|
||||
* not the complement of `omitted`: a truncated file may be represented here
|
||||
* and in `truncated`, while a notice-only file appears in neither. A genuinely
|
||||
* empty file counts when its heading survives because that heading conveys
|
||||
* that the instruction exists and has no content.
|
||||
*/
|
||||
represented: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
export interface WorkspaceInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
@@ -56,11 +67,15 @@ function byteLength(value: string): number {
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
|
||||
while (byteLength(truncated) > maxBytes) {
|
||||
truncated = truncated.slice(0, -1)
|
||||
const bytes = Buffer.from(value, 'utf8')
|
||||
if (bytes.length <= maxBytes) return value
|
||||
let end = Math.max(0, Math.trunc(maxBytes))
|
||||
// If the first excluded byte is a UTF-8 continuation byte, the budget cut
|
||||
// through that code point. Back up to its lead byte and exclude it too.
|
||||
while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) {
|
||||
end -= 1
|
||||
}
|
||||
return truncated
|
||||
return bytes.subarray(0, end).toString('utf8')
|
||||
}
|
||||
|
||||
function escapeInstructionFrameBody(body: string): string {
|
||||
@@ -143,6 +158,16 @@ function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
|
||||
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
|
||||
|
||||
function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
|
||||
if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
|
||||
return {
|
||||
...BASELINE_RENDER_STYLE,
|
||||
intro: files.length === 0
|
||||
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
|
||||
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
|
||||
}
|
||||
}
|
||||
|
||||
function changedSectionText(item: ChangeRenderItem): string {
|
||||
const { change, file } = item
|
||||
if (change.action === 'set') return additionalSectionText(file)
|
||||
@@ -178,13 +203,12 @@ export function renderInstructionChanges(
|
||||
},
|
||||
}
|
||||
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
const represented = new Set(rendered.represented.map(file => file.absolutePath))
|
||||
return {
|
||||
text: rendered.text,
|
||||
// TODO(rendered-change-proof): retain a transition only when its semantic
|
||||
// notice survived rendering; a tiny compact budget can currently return
|
||||
// unrelated notice text while still committing the full state transition.
|
||||
changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change),
|
||||
changes: items
|
||||
.filter(item => represented.has(item.file.absolutePath))
|
||||
.map(item => item.change),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,47 +276,75 @@ function renderInstructionContext(
|
||||
files: LoadedInstructionFile[],
|
||||
maxBytes: number,
|
||||
style: RenderStyle,
|
||||
): RenderedWorkspaceContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] }
|
||||
): RenderedInstructionContext {
|
||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
|
||||
return { text: '', omitted: files, truncated: [], represented: [] }
|
||||
}
|
||||
|
||||
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
||||
if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] }
|
||||
if (byteLength(fullText) <= maxBytes) {
|
||||
return { text: fullText, omitted: [], truncated: [], represented: files }
|
||||
}
|
||||
|
||||
for (let start = 1; start < files.length; start += 1) {
|
||||
const included = files.slice(start)
|
||||
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
|
||||
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] }
|
||||
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], represented: included }
|
||||
}
|
||||
|
||||
const mostSpecific = files.at(-1)
|
||||
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
|
||||
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] }
|
||||
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] }
|
||||
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||
const originalBytes = byteLength(mostSpecific.content)
|
||||
|
||||
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
|
||||
const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
|
||||
const includedBytes = byteLength(truncatedFile.content)
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes: byteLength(mostSpecific.content),
|
||||
includedBytes: byteLength(truncatedFile.content),
|
||||
originalBytes,
|
||||
includedBytes,
|
||||
}]
|
||||
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
||||
if (byteLength(text) <= maxBytes) return { text, omitted, truncated }
|
||||
if (byteLength(text) <= maxBytes) {
|
||||
const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text, omitted, truncated, represented }
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = [{
|
||||
displayPath: mostSpecific.displayPath,
|
||||
originalBytes: byteLength(mostSpecific.content),
|
||||
originalBytes,
|
||||
includedBytes: 0,
|
||||
}]
|
||||
const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated))
|
||||
const compactWithHeading = escapeInstructionFrameBody(
|
||||
[compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
|
||||
)
|
||||
if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated }
|
||||
if (byteLength(compactWithHeading) <= maxBytes) {
|
||||
const represented = originalBytes === 0 ? [mostSpecific] : []
|
||||
return { text: compactWithHeading, omitted, truncated, represented }
|
||||
}
|
||||
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
|
||||
return { text, omitted, truncated }
|
||||
return { text, omitted, truncated, represented: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a baseline together with the exact source files semantically represented in it.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded public rendering plus files with surviving content, including genuinely empty files.
|
||||
* @internal
|
||||
*/
|
||||
export function renderWorkspaceInstructionSet(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } {
|
||||
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
|
||||
return { rendered, included: represented }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,13 +357,5 @@ export function renderWorkspaceContext(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): RenderedWorkspaceContext {
|
||||
const style = options.replacePreviousBaseline === true
|
||||
? {
|
||||
...BASELINE_RENDER_STYLE,
|
||||
intro: files.length === 0
|
||||
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
|
||||
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
|
||||
}
|
||||
: BASELINE_RENDER_STYLE
|
||||
return renderInstructionContext(files, options.maxBytes, style)
|
||||
return renderWorkspaceInstructionSet(files, options).rendered
|
||||
}
|
||||
|
||||
@@ -422,6 +422,10 @@ export async function reconcileInstructionContext(
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
// When no transition survived rendering (tiny budgets render notice-only
|
||||
// text), emit nothing and commit nothing — the uncommitted versions make the
|
||||
// next pass retry instead of spamming notice-only contexts.
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
return {
|
||||
context: workspaceContextHook(rendered.text, rendered.changes),
|
||||
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type InstructionVersionCache,
|
||||
} from '../src/state.ts'
|
||||
import { resolveConfig } from '../src/config.ts'
|
||||
import { candidateScopeKey, renderInstructionChanges, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
|
||||
import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/** Per-candidate reconciliation scope key: directory paired with the file name. */
|
||||
@@ -855,6 +855,33 @@ describe('workspace context rendering', () => {
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120)
|
||||
})
|
||||
|
||||
it('represents a genuinely empty instruction when its compact heading fits', () => {
|
||||
const file = { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '' }
|
||||
const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 117 })
|
||||
|
||||
expect(rendered.rendered.text).toContain('truncated pkg/AGENTS.md from 0 to 0 bytes')
|
||||
expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||||
expect(rendered.included).toEqual([file])
|
||||
})
|
||||
|
||||
it('represents a genuinely empty instruction through the framed compact-intro path', () => {
|
||||
const file = {
|
||||
absolutePath: '/repo/pkg/AGENTS.md',
|
||||
displayPath: 'pkg/AGENTS.md',
|
||||
content: '',
|
||||
}
|
||||
|
||||
const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 300 })
|
||||
|
||||
expect(rendered.rendered.text).toContain('<system-reminder>')
|
||||
expect(rendered.rendered.text).toContain('Workspace instructions were omitted or truncated')
|
||||
expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||||
expect(rendered.rendered.truncated).toEqual([
|
||||
{ displayPath: 'pkg/AGENTS.md', originalBytes: 0, includedBytes: 0 },
|
||||
])
|
||||
expect(rendered.included).toEqual([file])
|
||||
})
|
||||
|
||||
it('truncates the compact notice itself when the render budget is smaller than the notice', () => {
|
||||
const rendered = renderWorkspaceContext([
|
||||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||||
@@ -865,6 +892,76 @@ describe('workspace context rendering', () => {
|
||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20)
|
||||
})
|
||||
|
||||
it('does not commit a change when only the generic compact notice survives', () => {
|
||||
const change = {
|
||||
action: 'set' as const,
|
||||
scope: sk('pkg', 'AGENTS.md'),
|
||||
path: 'pkg/AGENTS.md',
|
||||
digest: 'digest',
|
||||
}
|
||||
const rendered = renderInstructionChanges([{
|
||||
change,
|
||||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||||
}], 20)
|
||||
|
||||
expect(rendered.text).toBe('Workspace instructio')
|
||||
expect(rendered.changes).toEqual([])
|
||||
})
|
||||
|
||||
it('commits a change when its file-specific semantic section survives truncation', () => {
|
||||
const change = {
|
||||
action: 'replace' as const,
|
||||
scope: sk('pkg', 'AGENTS.md'),
|
||||
path: 'pkg/AGENTS.md',
|
||||
digest: 'digest',
|
||||
}
|
||||
const rendered = renderInstructionChanges([{
|
||||
change,
|
||||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||||
}], 400)
|
||||
|
||||
expect(rendered.text).toContain('Updated instructions from: pkg/AGENTS.md')
|
||||
expect(rendered.changes).toEqual([change])
|
||||
})
|
||||
|
||||
// Each prose-derived budget is the smallest current value that retains the named heading plus a zero-byte marker.
|
||||
it.each([
|
||||
{ action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' },
|
||||
{ action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' },
|
||||
])('does not commit a $action change when its heading survives with zero content bytes', ({ action, maxBytes, heading }) => {
|
||||
const change = {
|
||||
action,
|
||||
scope: sk('pkg', 'AGENTS.md'),
|
||||
path: 'pkg/AGENTS.md',
|
||||
digest: 'digest',
|
||||
}
|
||||
const rendered = renderInstructionChanges([{
|
||||
change,
|
||||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||||
}], maxBytes)
|
||||
|
||||
expect(rendered.text).toContain(heading)
|
||||
expect(rendered.text).toContain('from 1000 to 0 bytes')
|
||||
expect(rendered.changes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not commit a multibyte change when the budget cuts its first code point', () => {
|
||||
const change = {
|
||||
action: 'set' as const,
|
||||
scope: sk('pkg', 'AGENTS.md'),
|
||||
path: 'pkg/AGENTS.md',
|
||||
digest: 'digest',
|
||||
}
|
||||
const rendered = renderInstructionChanges([{
|
||||
change,
|
||||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '😀'.repeat(100) },
|
||||
}], 366)
|
||||
|
||||
expect(rendered.text).not.toContain('<27>')
|
||||
expect(rendered.text).not.toContain('😀')
|
||||
expect(rendered.changes).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
|
||||
const rendered = renderWorkspaceContext([
|
||||
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
|
||||
@@ -1832,21 +1929,30 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => {
|
||||
it.each([10, 120])('does not expose state markers when baseline content is omitted at %i bytes', async (maxBytes) => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
await write(join(root, 'AGENTS.md'), 'x'.repeat(1000))
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(agent.session.events.filter(event =>
|
||||
const contexts = agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind !== 'user',
|
||||
)).toHaveLength(1)
|
||||
)
|
||||
expect(contexts).toHaveLength(1)
|
||||
const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
|
||||
expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([])
|
||||
if (maxBytes === 120) {
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
|
||||
expect(derivedText(agent)).toContain('from 1000 to 0 bytes')
|
||||
} else {
|
||||
expect(derivedText(agent)).not.toContain('Instructions from: AGENTS.md')
|
||||
}
|
||||
expect(derivedText(agent)).not.toContain('workspace-context:')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -4167,6 +4273,47 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a nested instruction touch when only a truncated budget notice was rendered', async () => {
|
||||
const root = join(await tempRepo(), 'virtual-repo')
|
||||
const home = join(await tempRepo(), 'virtual-home')
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
const instructionPath = join(root, 'pkg/AGENTS.md')
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) })
|
||||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||||
})
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||||
})
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
|
||||
expect(first.additionalContexts).toBeUndefined()
|
||||
expect(second.additionalContexts).toBeUndefined()
|
||||
// Nothing was emitted, and the uncommitted version made the second sync
|
||||
// probe the instruction file again — the retry.
|
||||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||||
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dirname(root), { recursive: true, force: true })
|
||||
await rm(dirname(home), { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not attach nested instructions after a failed file read', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -4253,7 +4400,7 @@ describe('workspace context inbox synchronization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a dynamic change within a one-byte positive render budget', async () => {
|
||||
it('holds back a dynamic change a one-byte positive render budget cannot represent', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
@@ -4271,8 +4418,10 @@ describe('workspace context inbox synchronization', () => {
|
||||
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
|
||||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||||
expect(Buffer.byteLength(blocksText(agent.inbox.nextStep[0]?.content), 'utf8')).toBeLessThanOrEqual(1)
|
||||
// One byte cannot semantically represent the transition, so nothing is
|
||||
// emitted and nothing commits — the uncommitted version retries on the
|
||||
// next touch instead of committing state the model never saw.
|
||||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
|
||||
README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e
|
||||
README.zh.md: d7766b432c5a319d214da80e3df438489519be92
|
||||
README.md: 21851ca887147364c76612bae2e6a00ebdccec39
|
||||
README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f
|
||||
|
||||
@@ -19,7 +19,7 @@ tools:
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
|
||||
@@ -19,7 +19,7 @@ tools:
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
|
||||
|
||||
@@ -647,13 +647,14 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
||||
* scoped registrations or the reserved Code Mode transport.
|
||||
* Per-scope filter over the tools a scope INHERITS — the global layer and
|
||||
* every ancestor layer on its chain. Restrictions intersect, and do not affect
|
||||
* the scope's own registrations or the reserved Code Mode transport.
|
||||
*/
|
||||
export interface ToolRestriction {
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
/** Inherited tool names that stay visible; every other inherited one is removed. */
|
||||
readonly allow?: readonly string[]
|
||||
/** Global tool names removed from visibility. */
|
||||
/** Inherited tool names removed from visibility. */
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
|
||||
@@ -669,7 +670,7 @@ interface ToolView {
|
||||
readonly visible: ReadonlyMap<string, ToolDefinition>
|
||||
/** Pre-restriction capability names used by prompt-order validation. */
|
||||
readonly knownNames: ReadonlySet<string>
|
||||
/** Current global names that a scoped restriction may name. */
|
||||
/** Current inherited names a scoped restriction may name; its own are exempt. */
|
||||
readonly restrictableNames: ReadonlySet<string>
|
||||
}
|
||||
|
||||
@@ -707,7 +708,7 @@ class ToolLayer implements ScopeLayer {
|
||||
&& this.mode === undefined
|
||||
}
|
||||
|
||||
/** Whether every compiled restriction in this layer admits a global tool name. */
|
||||
/** Whether every compiled restriction in this layer admits an inherited tool name. */
|
||||
admits(name: string): boolean {
|
||||
for (const filter of this.restrictions.values()) {
|
||||
if ((filter.allow !== undefined && !filter.allow.has(name))
|
||||
@@ -1029,7 +1030,7 @@ export class ToolRegistry extends Service {
|
||||
const known = this.view(scope).restrictableNames
|
||||
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
@@ -1070,30 +1071,54 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Resolve every registry fact one scope needs in one layer traversal. The
|
||||
* visible map applies global restrictions, scoped shadowing, and the reserved
|
||||
* presentation transport; the other sets retain the pre-restriction facts
|
||||
* needed by restriction and prompt-order validation.
|
||||
* visible map applies restrictions to the INHERITED surface, then the
|
||||
* scope's own registrations and the reserved presentation transport; the
|
||||
* other sets retain the pre-restriction facts needed by restriction and
|
||||
* prompt-order validation.
|
||||
*
|
||||
* A restriction filters what a scope inherits — the global layer and every
|
||||
* ancestor layer on its chain — and never what its OWN layer registers.
|
||||
* That exemption is what a per-child capability filter has to keep intact:
|
||||
* the delegation runtime registers a child's reporting and structured-output
|
||||
* tools into the child's own layer, and a filter naming the capabilities the
|
||||
* child may use must not strip the machinery it answers through.
|
||||
*
|
||||
* Reading the exempt set as "the global layer" instead of "not mine" held
|
||||
* only while every model-facing tool sat in the host composition. Once
|
||||
* presets moved them onto the agent plane they became an ANCESTOR
|
||||
* contribution, so a child's filter silently stopped constraining anything
|
||||
* it was given.
|
||||
* @param scope - the viewing scope (the agent), or undefined for the global view.
|
||||
* @returns the complete derived view for that scope.
|
||||
*/
|
||||
private view(scope?: ScopeKey): ToolView {
|
||||
// Scope-chain layers, farthest ancestor first, the exact scope last.
|
||||
const layers = this.layers.chainLayers(scope)
|
||||
// Chain-blind on purpose: this is the ONE layer whose registrations the
|
||||
// scope owns rather than inherits, and it is absent until the scope
|
||||
// contributes something.
|
||||
const own = this.layers.peek(scope)
|
||||
// Inherited surface, nearest ancestor last: a nearer scope's same-name
|
||||
// entry shadows a farther one, and the global layer is the farthest.
|
||||
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
|
||||
for (const layer of layers) {
|
||||
if (layer === own) continue
|
||||
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
|
||||
}
|
||||
const visible = new Map<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
for (const [name, definition] of this.layers.global.tools.entries()) {
|
||||
for (const [name, definition] of inherited) {
|
||||
knownNames.add(name)
|
||||
restrictableNames.add(name)
|
||||
// Restrictions intersect across the whole chain: any scope on it may
|
||||
// mask a global-surface name for everything nested inside it.
|
||||
// mask an inherited name for everything nested inside it.
|
||||
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
|
||||
}
|
||||
// Chain layers second, nearest last: same-name entries REPLACE (shadow)
|
||||
// the global and farther-scope ones, and scope-local registrations are
|
||||
// never part of the global filter above.
|
||||
for (const layer of layers) {
|
||||
for (const [name, definition] of layer.tools.entries()) {
|
||||
// The scope's own registrations last, shadowing an inherited name and
|
||||
// outside the filter above.
|
||||
if (own !== undefined) {
|
||||
for (const [name, definition] of own.tools.entries()) {
|
||||
knownNames.add(name)
|
||||
visible.set(name, definition)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -181,21 +181,84 @@ describe('restrict()', () => {
|
||||
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
|
||||
it('fails loud on an unscoped call, an empty filter, and names it does not inherit', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('real'))
|
||||
scope.ctx.tools.register(tool('local'))
|
||||
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
|
||||
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
|
||||
// A scope's own registration is exempt from its own filter, so naming it
|
||||
// is a caller error rather than a silent no-op.
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/)
|
||||
|
||||
const emptyCtx = await mount()
|
||||
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
|
||||
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
|
||||
.toThrow(/known global tools: \(none\)/)
|
||||
.toThrow(/Restrictable tools: \(none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restrict() over an inherited scope layer', () => {
|
||||
/** Mint a child scope parented to `parent`, as a subagent's creation window does. */
|
||||
async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> {
|
||||
const key = { id: name as SessionId } as Agent
|
||||
bindScopeParent(key, parentKey)
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
|
||||
{ inject: ['tools', 'systemPrompt'] }))
|
||||
return { scope, key }
|
||||
}
|
||||
|
||||
it('filters tools the child inherits from an ancestor scope, not only global ones', async () => {
|
||||
// The shape every preset deployment has: no model-facing row in the global
|
||||
// layer, all of them contributed by an ancestor scope the child joined.
|
||||
const ctx = await mount()
|
||||
const parent = await mintAgentScope(ctx, 'parent')
|
||||
parent.scope.ctx.tools.register(tool('bash'))
|
||||
parent.scope.ctx.tools.register(tool('read'))
|
||||
const child = await mintChild(ctx, parent.key, 'child')
|
||||
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
|
||||
child.scope.ctx.tools.restrict({ deny: ['bash'] })
|
||||
|
||||
// Reading the exempt set as "the global layer" left this unfiltered, and
|
||||
// the name unrestrictable in the first place.
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read'])
|
||||
expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"')
|
||||
// The ancestor keeps its whole surface: a child's filter is its own.
|
||||
expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
|
||||
})
|
||||
|
||||
it('keeps the child\'s own registrations outside its own filter', async () => {
|
||||
// The delegation runtime registers a child's reporting and structured
|
||||
// output tools into the child's own layer; an `allow` naming only the
|
||||
// capabilities the child may use must not strip them.
|
||||
const ctx = await mount()
|
||||
const parent = await mintAgentScope(ctx, 'parent')
|
||||
parent.scope.ctx.tools.register(tool('bash'))
|
||||
parent.scope.ctx.tools.register(tool('read'))
|
||||
const child = await mintChild(ctx, parent.key, 'child')
|
||||
child.scope.ctx.tools.register(tool('report'))
|
||||
|
||||
child.scope.ctx.tools.restrict({ allow: ['read'] })
|
||||
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report'])
|
||||
expect(await run(ctx, 'report', child.key)).toBe('ran:report')
|
||||
})
|
||||
|
||||
it('lets an ancestor\'s restriction reach every scope nested inside it', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('web'))
|
||||
const parent = await mintAgentScope(ctx, 'parent')
|
||||
parent.scope.ctx.tools.register(tool('bash'))
|
||||
const child = await mintChild(ctx, parent.key, 'child')
|
||||
parent.scope.ctx.tools.restrict({ deny: ['web'] })
|
||||
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash'])
|
||||
expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/fs/fs-local/README.md
|
||||
README.md: 7b993fa123d13833313ecf3f78c64e466b960d5d
|
||||
README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5
|
||||
README.md: a3239905e3eebaae7fa3099122ee3a4ed91d3fe8
|
||||
README.zh.md: bbd9d2f66c4e582011bd0ea459e6c342eb653bda
|
||||
|
||||
@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback.
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
@@ -35,9 +35,9 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)).
|
||||
- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`).
|
||||
- **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard.
|
||||
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.
|
||||
- **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or presentation's whole-file fallback.
|
||||
- **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits.
|
||||
- **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized.
|
||||
- **Guarded creation requires hard-link support** — filesystems or mounts that reject hard-link publication cannot serve `createIfAbsent`; the provider preserves the missing target and reports `FS_IO_ERROR`.
|
||||
|
||||
@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
|
||||
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
|
||||
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
|
||||
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
|
||||
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。
|
||||
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
|
||||
|
||||
包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。
|
||||
@@ -35,9 +35,9 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。
|
||||
- **覆盖会把整个旧文件读入内存**:只用于 UI diff;在大小阈值之上限制这次预读取的工作延期处理(`TODO(overwrite-diff-bound)`)。
|
||||
- **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。
|
||||
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
|
||||
- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。
|
||||
- **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。
|
||||
- **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。
|
||||
- **带防护的创建要求支持硬链接**:拒绝硬链接发布的文件系统或挂载点无法支持 `createIfAbsent`;提供方会使目标保持缺失状态并报告 `FS_IO_ERROR`。
|
||||
|
||||
@@ -15,6 +15,8 @@ import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
// Bound one non-abortable FileHandle.read so cancellation is observed between chunks.
|
||||
const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
@@ -74,9 +76,8 @@ function versionOf(info: BigIntStats): FsVersion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hook: lets specs pin the atomic-write temp names (to prove
|
||||
* exclusive-open behavior without a name race) and observe the staged temp
|
||||
* file before it is renamed over the target.
|
||||
* Test hook: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without
|
||||
* a name race), override native boundaries, and observe the staged temp file before publication.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override the host platform for native-publication unit coverage. */
|
||||
@@ -634,21 +635,67 @@ export async function readForEdit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still
|
||||
* succeeds and presentation falls back to a whole-file diff.
|
||||
* @param absolutePath - the file to read (typically a target key); it must exist.
|
||||
* @param signal - aborts the read (`FS_ABORTED`).
|
||||
* @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
|
||||
* Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
|
||||
* or a file deleted/made unreadable after the caller's preflight returns `null` so the write
|
||||
* still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
|
||||
* the opened descriptor rather than a prior path stat, so concurrent external replacement or
|
||||
* size changes cannot make this helper buffer more than `maxBytes`.
|
||||
* @param absolutePath - the file to read (typically a target key).
|
||||
* @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
|
||||
* @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
|
||||
* @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
|
||||
* descriptor-size-changed, or unreadable file.
|
||||
*/
|
||||
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
|
||||
const buffer = await readFileAbortable(absolutePath, 'read', signal)
|
||||
if (buffer.includes(0)) return null
|
||||
export async function readTextForDiff(
|
||||
absolutePath: string,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
throwIfAborted(signal, 'read')
|
||||
try {
|
||||
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer))
|
||||
const handle = await open(absolutePath, 'r')
|
||||
let buffer: Buffer
|
||||
let total = 0
|
||||
let openedSize = 0
|
||||
try {
|
||||
throwIfAborted(signal, 'read')
|
||||
const info = await handle.stat()
|
||||
throwIfAborted(signal, 'read')
|
||||
if (!info.isFile()) return null
|
||||
if (info.size >= maxBytes) return null
|
||||
openedSize = info.size
|
||||
// One extra byte detects growth after stat without retaining per-read backing buffers.
|
||||
buffer = Buffer.allocUnsafe(openedSize + 1)
|
||||
while (total < buffer.length) {
|
||||
throwIfAborted(signal, 'read')
|
||||
const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES)
|
||||
const { bytesRead } = await handle.read(buffer, total, length, null)
|
||||
if (bytesRead === 0) break
|
||||
total += bytesRead
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
throwIfAborted(signal, 'read')
|
||||
if (total !== openedSize) return null
|
||||
const basis = buffer.subarray(0, total)
|
||||
if (basis.includes(0)) return null
|
||||
try {
|
||||
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis))
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes;
|
||||
* any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
return null
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
return null
|
||||
// Cancellation is the caller's intent and still propagates.
|
||||
if (error instanceof FsError) throw error
|
||||
// A descriptor-phase errno — deleted or made unreadable after the caller's
|
||||
// preflight, or a faulted read — costs only the optional basis: a committed
|
||||
// write must not fail for a presentation-only pre-read.
|
||||
if (error instanceof Error && 'code' in error) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { constants as bufferConstants } from 'node:buffer'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import z from 'schemastery'
|
||||
@@ -39,9 +40,19 @@ import type { FsIoInternals } from './fsio.ts'
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the
|
||||
* runtime's safe allocation/decode maximum. Defaults to 10 MiB.
|
||||
*/
|
||||
diffBasisMaxBytes?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
|
||||
const MAX_DIFF_BASIS_BYTES = Math.min(
|
||||
bufferConstants.MAX_LENGTH,
|
||||
bufferConstants.MAX_STRING_LENGTH,
|
||||
)
|
||||
|
||||
/**
|
||||
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
||||
@@ -52,11 +63,12 @@ type ResolvedConfig = Required<Config>
|
||||
export class LocalFileSystem extends FileSystem {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string().default(process.cwd()),
|
||||
diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
/** Test hook forwarded to fsio (force streaming path, pin temp names). */
|
||||
/** Test hook forwarded to fsio for atomic-publication boundaries. */
|
||||
internals: FsIoInternals = {}
|
||||
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
||||
* window can't interleave, making concurrent writes/edits deterministically
|
||||
@@ -65,7 +77,13 @@ export class LocalFileSystem extends FileSystem {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = config as ResolvedConfig
|
||||
const resolved = config as ResolvedConfig
|
||||
if (!Number.isSafeInteger(resolved.diffBasisMaxBytes)
|
||||
|| resolved.diffBasisMaxBytes <= 0
|
||||
|| resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) {
|
||||
throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`)
|
||||
}
|
||||
this.config = resolved
|
||||
}
|
||||
|
||||
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
||||
@@ -164,9 +182,16 @@ export class LocalFileSystem extends FileSystem {
|
||||
}
|
||||
// No expectation means an unconditional but still atomic write.
|
||||
|
||||
// Preserve prior text for contextual diffs; null falls back to a whole-file diff.
|
||||
// TODO(overwrite-diff-bound): cap this UI-only pre-read for large files.
|
||||
const before = existing ? await readTextForDiff(target.targetKey, signal) : null
|
||||
// Capture an optional contextual-diff basis before the write. The bounded
|
||||
// reader checks the opened file itself, so an external replacement after
|
||||
// `probe()` cannot turn this best-effort presentation read into an
|
||||
// unbounded allocation. Either side at/above the configured limit yields
|
||||
// `before: null`; consumers retain their whole-file fallback.
|
||||
const diffable = existing !== null
|
||||
&& Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes
|
||||
const before = diffable
|
||||
? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal)
|
||||
: null
|
||||
await writeFileAtomic(
|
||||
target.targetKey,
|
||||
content,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { constants as bufferConstants } from 'node:buffer'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -43,13 +44,39 @@ async function versionOf(target: FsTarget): Promise<FsVersion> {
|
||||
return info.version
|
||||
}
|
||||
|
||||
async function remountWithDiffLimit(diffBasisMaxBytes: number): Promise<void> {
|
||||
await fiber.dispose()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir, diffBasisMaxBytes })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
expect((bare.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(10 * 1024 * 1024)
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => {
|
||||
const maxDiffBasisBytes = Math.min(
|
||||
bufferConstants.MAX_LENGTH,
|
||||
bufferConstants.MAX_STRING_LENGTH,
|
||||
)
|
||||
const valid = new Context()
|
||||
const validFiber = await valid.plugin(LocalFileSystem, { diffBasisMaxBytes: maxDiffBasisBytes })
|
||||
expect((valid.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(maxDiffBasisBytes)
|
||||
await validFiber.dispose()
|
||||
|
||||
for (const diffBasisMaxBytes of [0, -1, 1.5, maxDiffBasisBytes + 1, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const invalid = new Context()
|
||||
await expect(invalid.plugin(LocalFileSystem, { diffBasisMaxBytes })).rejects.toThrow(
|
||||
`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${maxDiffBasisBytes}`,
|
||||
)
|
||||
await invalid.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
@@ -444,6 +471,54 @@ describe('writeText', () => {
|
||||
expect(outcome.after).toBe('now valid')
|
||||
})
|
||||
|
||||
it('an overwrite of a prior file AT the whole-file bound reports before:null (undiffable), still succeeds', async () => {
|
||||
// The configured bound keeps the fixture small; 8 bytes at a bound of 8
|
||||
// pins the exclusive edge without coupling this provider to a read tool.
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'big.txt'), '12345678')
|
||||
const target = await fs.resolve('big.txt')
|
||||
const outcome = await fs.writeText(target, 'tiny')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('tiny')
|
||||
})
|
||||
|
||||
it('an overwrite whose NEW content is at the whole-file bound reports before:null (no huge contextual diff)', async () => {
|
||||
// The bound gates BOTH sides of the diff pair: a small prior file rewritten
|
||||
// with at/above-bound content yields no contextual-hunk basis either, since
|
||||
// a small-to-huge rewrite's hunk is as large as the new content — the
|
||||
// consumer must fall back to the whole-file diff card, exactly like a
|
||||
// create of the same size.
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'grow.txt'), 'tiny')
|
||||
const target = await fs.resolve('grow.txt')
|
||||
const outcome = await fs.writeText(target, '12345678')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('12345678')
|
||||
})
|
||||
|
||||
it('gates the NEW content by UTF-8 byte length, not character count', async () => {
|
||||
// Three CJK characters are 9 UTF-8 bytes: below an 8-byte bound by
|
||||
// characters but at/above it by bytes, so the basis must be declined.
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'cjk.txt'), 'tiny')
|
||||
const target = await fs.resolve('cjk.txt')
|
||||
const outcome = await fs.writeText(target, '你好吗')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('你好吗')
|
||||
})
|
||||
|
||||
it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => {
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'small.txt'), '1234567')
|
||||
const target = await fs.resolve('small.txt')
|
||||
const outcome = await fs.writeText(target, 'new')
|
||||
expect(outcome.before).toBe('1234567')
|
||||
expect(outcome.after).toBe('new')
|
||||
})
|
||||
|
||||
it('releases per-target mutation locks after success and failure', async () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
@@ -316,6 +317,261 @@ describe('readWholeText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextForDiff', () => {
|
||||
it('returns normalized text only when the opened file is strictly below the limit', async () => {
|
||||
const file = join(dir, 'basis.txt')
|
||||
await writeFile(file, 'a\r\nb')
|
||||
expect(await readTextForDiff(file, 5)).toBe('a\nb')
|
||||
expect(await readTextForDiff(file, 4)).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds the actual opened file rather than trusting an earlier path size', async () => {
|
||||
const file = join(dir, 'replaced.txt')
|
||||
await writeFile(file, 'tiny')
|
||||
const earlierSize = (await stat(file)).size
|
||||
await writeFile(file, '123456789')
|
||||
expect(earlierSize).toBeLessThan(8)
|
||||
expect(await readTextForDiff(file, 8)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the opened file shrinks after descriptor stat', async () => {
|
||||
const file = join(dir, 'shrinking.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
const info = await handle.stat(...statArgs)
|
||||
await writeFile(file, 'abc')
|
||||
return info
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
expect(await isolatedReadTextForDiff(file, 8)).toBeNull()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when the opened file grows after descriptor stat', async () => {
|
||||
const file = join(dir, 'growing.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
const info = await handle.stat(...statArgs)
|
||||
await writeFile(file, 'abcdef-grown')
|
||||
return info
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when the file vanishes before the basis open (deletion race)', async () => {
|
||||
expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the opened descriptor is no longer a regular file', async () => {
|
||||
const file = join(dir, 'swapped.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
const info = await handle.stat(...statArgs)
|
||||
return Object.assign(info, { isFile: () => false })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates a non-errno fault instead of masking it as a null basis', async () => {
|
||||
const file = join(dir, 'faulted.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open() {
|
||||
throw new TypeError('forged programming fault')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault')
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
expect(await readTextForDiff(join(dir, 'bin'), 8)).toBeNull()
|
||||
expect(await readTextForDiff(join(dir, 'bad'), 8)).toBeNull()
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'basis.txt')
|
||||
await writeFile(file, 'text')
|
||||
await expect(readTextForDiff(file, 8, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it.each(['open', 'stat'] as const)('observes cancellation immediately after %s', async (stage) => {
|
||||
const file = join(dir, 'basis.txt')
|
||||
await writeFile(file, 'text')
|
||||
const reached = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let statCalls = 0
|
||||
const allocate = vi.spyOn(Buffer, 'allocUnsafe')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
if (stage === 'open') {
|
||||
reached.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
statCalls += 1
|
||||
const info = await handle.stat(...statArgs)
|
||||
if (stage === 'stat') {
|
||||
reached.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return info
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
const controller = new AbortController()
|
||||
const pending = isolatedReadTextForDiff(file, 8, controller.signal)
|
||||
await reached.promise
|
||||
const allocationCalls = allocate.mock.calls.length
|
||||
controller.abort()
|
||||
release.resolve(undefined)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(statCalls).toBe(stage === 'open' ? 0 : 1)
|
||||
expect(allocate).toHaveBeenCalledTimes(allocationCalls)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
allocate.mockRestore()
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds descriptor reads and observes cancellation before the next chunk', async () => {
|
||||
const file = join(dir, 'large-basis.txt')
|
||||
const fileBytes = 200 * 1024
|
||||
await writeFile(file, 'x'.repeat(fileBytes))
|
||||
const firstRead = Promise.withResolvers<undefined>()
|
||||
const releaseFirstRead = Promise.withResolvers<undefined>()
|
||||
const readLengths: number[] = []
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
stat: handle.stat.bind(handle),
|
||||
close: handle.close.bind(handle),
|
||||
async read(buffer: Buffer, offset: number, length: number, position: number | null) {
|
||||
readLengths.push(length)
|
||||
const result = await handle.read(buffer, offset, length, position)
|
||||
if (readLengths.length === 1) {
|
||||
firstRead.resolve(undefined)
|
||||
await releaseFirstRead.promise
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
const controller = new AbortController()
|
||||
const pending = isolatedReadTextForDiff(file, fileBytes + 1, controller.signal)
|
||||
await firstRead.promise
|
||||
expect(readLengths).toEqual([64 * 1024])
|
||||
controller.abort()
|
||||
releaseFirstRead.resolve(undefined)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(readLengths).toHaveLength(1)
|
||||
} finally {
|
||||
releaseFirstRead.resolve(undefined)
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
|
||||
@@ -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/fs/fs-sandbox/README.md
|
||||
README.md: c40fc7999ab85a70702f65a5675208163f5fc351
|
||||
README.zh.md: 15db5abbfc5307c0570925026ec435d8dbb51bf2
|
||||
README.md: ae1fd746c711a86e308a02e0054ba478e8d913c0
|
||||
README.zh.md: e25a3467c06fbd93de9bb75d6b364e8da5a451fe
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
|
||||
|
||||
Its plugin config is the local backend config unchanged: `cwd` remains the relative-path resolution default, and `diffBasisMaxBytes` bounds the optional overwrite contextual-diff basis.
|
||||
|
||||
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots.
|
||||
|
||||
## The fence
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
`SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。
|
||||
|
||||
它原样复用本地后端配置:`cwd` 仍是相对路径的解析默认值,`diffBasisMaxBytes` 则限制可选的覆写上下文 diff 基础。
|
||||
|
||||
只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策略,因此两个能力族绝不会约束到不同根目录。
|
||||
|
||||
## 围栏
|
||||
|
||||
@@ -41,10 +41,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { isPathUnder } from './containment.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
|
||||
* base for relative paths). The sandbox default (mode + `workspace-write`
|
||||
* fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
|
||||
* session for every enforcing capability.
|
||||
* Plugin config: the local backend's knobs verbatim (`cwd` resolution default
|
||||
* and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default
|
||||
* (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`
|
||||
* resolves each calling session for every enforcing capability.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
|
||||
@@ -132,10 +132,11 @@ export interface FsWriteOutcome {
|
||||
version: FsVersion
|
||||
/**
|
||||
* The file's content BEFORE the write, or `null` when the file did not exist
|
||||
* (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
|
||||
* (the diff basis), never a diff — a consumer computes the result-time
|
||||
* contextual diff from `before`/`after` when `before` is present, else falls
|
||||
* back to a whole-file diff.
|
||||
* (a create) or the backend declined a contextual basis (for example, a
|
||||
* binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit).
|
||||
* LF-normalized storage text (the diff basis), never a diff — a consumer
|
||||
* computes the result-time contextual diff from `before`/`after` when
|
||||
* `before` is present, else falls back to a whole-file diff.
|
||||
*/
|
||||
before: string | null
|
||||
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PresetNotWritableError, resolveSessionPreset,
|
||||
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
@@ -1037,7 +1038,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* common paths — reconnecting, resuming, retrying a create — are unaffected.
|
||||
* @param sessionId - the identity being adopted.
|
||||
* @param requested - the preset the request named, if any.
|
||||
* @param existing - the preset the session was created under, if any.
|
||||
* @param existing - the preset the session RUNS, if any; both callers resolve
|
||||
* it from the log, which differs from the creation header once a blank
|
||||
* session has switched.
|
||||
* @throws when both are present and differ.
|
||||
*/
|
||||
function assertPresetUnchanged(
|
||||
@@ -1350,17 +1353,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* The registry view scope a transcript's presenters resolve in.
|
||||
*
|
||||
* A live agent is that scope itself (its chain passes through its preset's
|
||||
* standing layer). A cold session names its preset on the header, and the
|
||||
* standing layer). A cold session resolves its preset from the LOG, and the
|
||||
* preset's STANDING key serves without resuming anything — ensuring the
|
||||
* mount composes plugins but starts no agent, session, or turn. No roster,
|
||||
* no recorded preset, or a preset the roster no longer supplies all fall
|
||||
* back to the global layer: the transcript still serves, with the generic
|
||||
* cards a viewless entry renders.
|
||||
*
|
||||
* Reading the header alone would render a session that switched while blank
|
||||
* through the composition it was CREATED with. Every tool only the newer
|
||||
* preset registers resolves to no presenter there, and the transcript
|
||||
* silently degrades to generic cards for exactly the calls its history is
|
||||
* made of.
|
||||
* @param sessionId - the transcript being read.
|
||||
* @param header - that session's header (attached or inspected).
|
||||
* @param session - that session's header and log (attached or inspected).
|
||||
* @returns the scope to pass to presenter lookups, or undefined for global.
|
||||
*/
|
||||
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
|
||||
async function presenterScopeFor(
|
||||
sessionId: SessionId,
|
||||
session: PresetBearingSession,
|
||||
): Promise<ScopeKey | undefined> {
|
||||
const live = ctx.get('agents')?.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const presets = ctx.get('agentPresets')
|
||||
@@ -1370,7 +1382,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// through the DEFAULT preset's standing layer: that is the composition
|
||||
// an unnamed session composes today, and presenters are pure display,
|
||||
// so the worst a mismatch produces is the generic card it had anyway.
|
||||
return await presets.standingKeyFor(header.agentPreset)
|
||||
return await presets.standingKeyFor(resolveSessionPreset(session))
|
||||
} catch {
|
||||
// Swallows only the unknown/unusable-preset rejection from the roster:
|
||||
// a deleted or broken preset must degrade this read, never fail it.
|
||||
@@ -1463,7 +1475,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Beside the cwd check for the same reason, and after the await so it
|
||||
// covers every path that yields a live agent — freshly created, adopted
|
||||
// live, resumed from disk, or recovered by the concurrent-creation catch.
|
||||
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
|
||||
assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
@@ -1979,12 +1991,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
}
|
||||
// Echo the RESOLVED composition so a client can label the session it
|
||||
// just created without waiting for the next list refresh — the create
|
||||
// is the commit point that knows it (a caller that named none gets
|
||||
// the default the header recorded).
|
||||
// Echo the composition the session RUNS so a client can label it
|
||||
// without waiting for the next list refresh — the create is the commit
|
||||
// point that knows it (a caller that named none gets the default).
|
||||
// Resolved from the log for the same reason `sessionListFields()` is:
|
||||
// this handler also adopts an already-live session, and one that
|
||||
// switched while blank runs a preset its header no longer names, so
|
||||
// echoing the header would contradict both the adoption this call just
|
||||
// allowed and the row `session.list` serves for the same session.
|
||||
const created = ctx.agents.get(sessionId)
|
||||
const createdPreset = created?.session.header.agentPreset
|
||||
const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
|
||||
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
|
||||
},
|
||||
|
||||
@@ -2003,7 +2019,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
@@ -2982,7 +2998,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// The scope presenters resolve in — the live agent, else the recorded
|
||||
// preset's standing key, else the global layer — so a cold session's
|
||||
// '/' popup lists the catalog its composition actually serves.
|
||||
const scope = await presenterScopeFor(sessionId, session.header)
|
||||
const scope = await presenterScopeFor(sessionId, session)
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
||||
return ok(request, {
|
||||
|
||||
@@ -186,6 +186,29 @@ describe('session.create with an agent preset', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts a live session under the preset it SWITCHED to', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
|
||||
// Exactly what `agentPreset.select` leaves behind on a blank session: the
|
||||
// header keeps the creation fact, the log states what the agent runs.
|
||||
ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }))
|
||||
const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
|
||||
|
||||
// Comparing against the header would invert both answers: the preset the
|
||||
// session actually runs would be refused, and the one it left would pass.
|
||||
expect(adopted.result.ok).toBe(true)
|
||||
// The echo has to name the same preset the adoption just accepted, or the
|
||||
// client labels the session with one it has already left — and disagrees
|
||||
// with the row `session.list` serves for it.
|
||||
if (!adopted.result.ok) throw new Error('unreachable')
|
||||
expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' })
|
||||
expect(stale.result.ok).toBe(false)
|
||||
if (stale.result.ok) throw new Error('unreachable')
|
||||
expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' })
|
||||
})
|
||||
|
||||
it('adopts a live session unchanged when the caller names no preset', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
|
||||
@@ -660,6 +683,27 @@ describe('session.history presenter scope', () => {
|
||||
expect(standingKeyRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves a switched session from the LOG, not its creation header', async () => {
|
||||
// The header is a creation fact; a switch while blank is a logged event,
|
||||
// and every turn after it ran under the newer composition. Reading the
|
||||
// header would render that history through the older preset's layer,
|
||||
// where the tools it is made of have no presenter at all.
|
||||
const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' }
|
||||
const { api } = await harness(['standard', 'minimal'], {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({
|
||||
meta,
|
||||
events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }],
|
||||
}),
|
||||
})
|
||||
|
||||
standingKeyRequests.length = 0
|
||||
const response = await api.sessions.history(request({ sessionId: SessionId('p4') }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
expect(standingKeyRequests).toEqual(['minimal'])
|
||||
})
|
||||
|
||||
it('serves a COLD transcript whose standing mount is no longer usable', async () => {
|
||||
// A genuinely cold session: persistence knows it, no live agent exists.
|
||||
const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }
|
||||
|
||||
@@ -56,12 +56,14 @@
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +57,13 @@
|
||||
"react": "^18.2.0",
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/preset/agent-presets/README.md
|
||||
README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d
|
||||
README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21
|
||||
README.md: 632fc7828313a932512cb59a924050005067a008
|
||||
README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899
|
||||
|
||||
@@ -14,6 +14,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason.
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row.
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved.
|
||||
- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined).
|
||||
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
|
||||
@@ -27,6 +29,14 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
|
||||
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions.
|
||||
|
||||
### Composing a child agent
|
||||
|
||||
A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections.
|
||||
|
||||
Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window.
|
||||
|
||||
The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default.
|
||||
|
||||
### Which preset a session runs
|
||||
|
||||
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
|
||||
@@ -63,7 +73,7 @@ A preset may publish display text in an optional `preset.yml` beside its composi
|
||||
|
||||
```yaml
|
||||
name: 极简模式
|
||||
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。
|
||||
```
|
||||
|
||||
It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user